Composite cursor in Rust (#66697)

* Composite the cursor in Rust during ResizeCrop

* Apply code review feedback

* Update test

* Move CursorBitmap to an enum, remove Cow, avoid cloning the bitmap

* Remove dereference

* Invalidate E2E Rust cache on decoder changes
This commit is contained in:
Ryan Clark
2026-06-10 09:27:47 +00:00
committed by GitHub
parent 6c846235ed
commit de6fd032d6
12 changed files with 546 additions and 228 deletions
+4 -2
View File
@@ -111,7 +111,8 @@ jobs:
target/x86_64-unknown-linux-gnu/release/librdp_client.a
lib/srv/desktop/rdp/rdpclient/librdpclient.h
key: rust-output-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '**/Cargo.toml', 'Makefile', 'build.assets/Makefile',
'build.assets/versions.mk', 'rust-toolchain.toml', 'lib/srv/desktop/rdp/rdpclient/**/*.rs', 'crates/**/*.rs', 'web/packages/shared/libs/ironrdp/**/*.rs') }}
'build.assets/versions.mk', 'rust-toolchain.toml', 'lib/srv/desktop/rdp/rdpclient/**/*.rs', 'lib/srv/desktop/rdp/decoder/**/*.rs',
'crates/**/*.rs', 'web/packages/shared/libs/ironrdp/**/*.rs') }}
- name: Build Teleport, tctl, and tsh
run: ${{ inputs.build-command }}
@@ -164,7 +165,8 @@ jobs:
target/x86_64-unknown-linux-gnu/release/librdp_client.a
lib/srv/desktop/rdp/rdpclient/librdpclient.h
key: rust-output-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '**/Cargo.toml', 'Makefile', 'build.assets/Makefile',
'build.assets/versions.mk', 'rust-toolchain.toml', 'lib/srv/desktop/rdp/rdpclient/**/*.rs', 'crates/**/*.rs', 'web/packages/shared/libs/ironrdp/**/*.rs') }}
'build.assets/versions.mk', 'rust-toolchain.toml', 'lib/srv/desktop/rdp/rdpclient/**/*.rs', 'lib/srv/desktop/rdp/decoder/**/*.rs',
'crates/**/*.rs', 'web/packages/shared/libs/ironrdp/**/*.rs') }}
test:
name: E2E Tests
@@ -135,6 +135,7 @@ func (d *desktopThumbnailGenerator) produceThumbnail(maxDim int) (*pb.SessionRec
uint16(bounds.Min.X), uint16(bounds.Min.Y),
uint16(cropW), uint16(cropH),
uint16(thumbW), uint16(thumbH),
true,
)
if err != nil { //nolint:staticcheck // err is always non-nil in nop build but nil in RDP build
return nil, trace.Wrap(err, "resizing thumbnail crop")
+36 -45
View File
@@ -42,8 +42,6 @@ package decoder
#cgo noescape rdp_decoder_image_data
#cgo nocallback rdp_decoder_cursor_state
#cgo noescape rdp_decoder_cursor_state
#cgo nocallback rdp_decoder_cursor_bitmap
#cgo noescape rdp_decoder_cursor_bitmap
#cgo nocallback rdp_decoder_updated_regions_count
#cgo noescape rdp_decoder_updated_regions_count
#cgo nocallback rdp_decoder_updated_regions
@@ -52,6 +50,8 @@ package decoder
#cgo noescape rdp_decoder_reset_updated_regions
#cgo nocallback rdp_decoder_resize_crop
#cgo noescape rdp_decoder_resize_crop
#cgo nocallback rdp_decoder_set_cursor_position
#cgo noescape rdp_decoder_set_cursor_position
#cgo nocallback rdp_decoder_dimensions
#cgo noescape rdp_decoder_dimensions
#cgo nocallback rdp_decoder_bytes_per_pixel
@@ -71,11 +71,11 @@ void rdp_decoder_resize(RdpDecoder* ptr, uint16_t width, uint16_t height);
void rdp_decoder_process(RdpDecoder* ptr, const uint8_t* data, size_t len);
const uint8_t* rdp_decoder_image_data(RdpDecoder* ptr, uint16_t* width, uint16_t* height);
void rdp_decoder_cursor_state(RdpDecoder* ptr, uint8_t* out_visible, uint16_t* out_x, uint16_t* out_y);
const uint8_t* rdp_decoder_cursor_bitmap(RdpDecoder* ptr, uint16_t* out_width, uint16_t* out_height, uint16_t* out_hotspot_x, uint16_t* out_hotspot_y);
uint32_t rdp_decoder_updated_regions_count(RdpDecoder* ptr);
uint32_t rdp_decoder_updated_regions(RdpDecoder* ptr, uint16_t* out_buf, uint32_t max_count);
void rdp_decoder_reset_updated_regions(RdpDecoder* ptr);
bool rdp_decoder_resize_crop(RdpDecoder* ptr, uint16_t crop_x, uint16_t crop_y, uint16_t crop_w, uint16_t crop_h, uint16_t out_width, uint16_t out_height, uint8_t* out_buf, size_t out_buf_len);
bool rdp_decoder_resize_crop(RdpDecoder* ptr, uint16_t crop_x, uint16_t crop_y, uint16_t crop_w, uint16_t crop_h, uint16_t out_width, uint16_t out_height, uint8_t* out_buf, size_t out_buf_len, uint8_t with_cursor);
void rdp_decoder_set_cursor_position(RdpDecoder* ptr, uint16_t x, uint16_t y);
void rdp_decoder_dimensions(RdpDecoder* ptr, uint16_t* out_width, uint16_t* out_height);
uint8_t rdp_decoder_bytes_per_pixel(RdpDecoder* ptr);
uint64_t rdp_decoder_sample_hash(RdpDecoder* ptr, uint16_t sample_count);
@@ -161,8 +161,10 @@ func (d *Decoder) Image() *image.RGBA {
}
// ResizeCrop returns the source crop region (cropX, cropY, cropW, cropH) scaled to exactly outWidth x outHeight using
// high-quality CatmullRom convolution. The crop must lie within the current frame bounds.
func (d *Decoder) ResizeCrop(cropX, cropY, cropW, cropH, outWidth, outHeight uint16) (*image.RGBA, error) {
// high-quality CatmullRom convolution. The crop must lie within the current frame bounds. When withCursor is true and
// the decoder's tracked cursor is visible, it is composited onto the source frame before the crop is taken, so the
// cursor scales with the screen.
func (d *Decoder) ResizeCrop(cropX, cropY, cropW, cropH, outWidth, outHeight uint16, withCursor bool) (*image.RGBA, error) {
if d == nil || d.ptr == nil {
return nil, errors.New("decoder not initialized")
}
@@ -178,6 +180,11 @@ func (d *Decoder) ResizeCrop(cropX, cropY, cropW, cropH, outWidth, outHeight uin
w, h := int(outWidth), int(outHeight)
buf := make([]byte, w*h*bpp)
var withCursorC C.uint8_t
if withCursor {
withCursorC = 1
}
ok := C.rdp_decoder_resize_crop(
d.ptr,
C.uint16_t(cropX),
@@ -188,6 +195,7 @@ func (d *Decoder) ResizeCrop(cropX, cropY, cropW, cropH, outWidth, outHeight uin
C.uint16_t(outHeight),
(*C.uint8_t)(unsafe.SliceData(buf)),
C.size_t(len(buf)),
withCursorC,
)
if !bool(ok) {
return nil, errors.New("failed to resize crop region")
@@ -200,6 +208,28 @@ func (d *Decoder) ResizeCrop(cropX, cropY, cropW, cropH, outWidth, outHeight uin
}, nil
}
// SetCursorPosition overrides the decoder's tracked cursor position. Useful for cursor updates that arrive outside of
// the RDP fast-path stream (e.g., TDPB MouseMove events). Does not change visibility — visibility is still driven by
// RDP fast-path pointer updates.
func (d *Decoder) SetCursorPosition(x, y uint16) {
if d == nil || d.ptr == nil {
return
}
C.rdp_decoder_set_cursor_position(d.ptr, C.uint16_t(x), C.uint16_t(y))
}
// SampleHash returns a 64-bit FNV-1a digest of pixels sampled on a fixed grid from the current frame buffer.
// sampleCount controls the per-axis sample density; the effective step is max(1, dim / sampleCount).
// Matches Go hash/fnv.New64a() byte-for-byte for the same inputs.
func (d *Decoder) SampleHash(sampleCount uint16) uint64 {
if d == nil || d.ptr == nil {
return 0
}
return uint64(C.rdp_decoder_sample_hash(d.ptr, C.uint16_t(sampleCount)))
}
// Dimensions returns the current frame width and height in pixels. Returns (0, 0) if the decoder has not been initialized.
func (d *Decoder) Dimensions() (width, height uint16) {
if d == nil || d.ptr == nil {
@@ -212,16 +242,6 @@ func (d *Decoder) Dimensions() (width, height uint16) {
return uint16(w), uint16(h)
}
// SampleHash returns a 64-bit FNV-1a digest of pixels sampled on a fixed grid from the current frame buffer.
// sampleCount controls the per-axis sample density. Returns 0 if the decoder has not been initialized.
func (d *Decoder) SampleHash(sampleCount uint16) uint64 {
if d == nil || d.ptr == nil {
return 0
}
return uint64(C.rdp_decoder_sample_hash(d.ptr, C.uint16_t(sampleCount)))
}
// CursorState returns the cursor position and visibility as tracked by the
// Rust decoder.
func (d *Decoder) CursorState() CursorState {
@@ -241,35 +261,6 @@ func (d *Decoder) CursorState() CursorState {
}
}
// CursorBitmap returns the current cursor bitmap, or nil if none is available.
func (d *Decoder) CursorBitmap() *CursorBitmapData {
if d == nil || d.ptr == nil {
return nil
}
var bmpW, bmpH, hotX, hotY C.uint16_t
bmpData := C.rdp_decoder_cursor_bitmap(d.ptr, &bmpW, &bmpH, &hotX, &hotY)
if bmpData == nil || bmpW == 0 || bmpH == 0 {
return nil
}
w := int(bmpW)
h := int(bmpH)
cursorPix := make([]byte, w*h*4)
copy(cursorPix, unsafe.Slice((*uint8)(bmpData), w*h*4))
return &CursorBitmapData{
Image: &image.RGBA{
Pix: cursorPix,
Stride: w * 4,
Rect: image.Rect(0, 0, w, h),
},
HotspotX: int(hotX),
HotspotY: int(hotY),
}
}
// UpdatedRegions returns the individual screen regions updated since the last
// call to ResetUpdatedRegions. Each rectangle uses Go's exclusive Max convention
// (converted from the Rust decoder's inclusive coordinates).
@@ -16,10 +16,6 @@
package decoder
import (
"image"
)
// This file contains types and functions that are shared between the nop decoder (built without RDP decoder flag) and
// the decoder built with the RDP decoder flag.
@@ -29,13 +25,6 @@ type CursorState struct {
X, Y uint16
}
// CursorBitmapData holds the cursor bitmap image and hotspot offset.
type CursorBitmapData struct {
Image *image.RGBA
HotspotX int
HotspotY int
}
type decoderConfig struct {
ioChannelID, userChannelID uint16
}
+2 -2
View File
@@ -35,11 +35,11 @@ func (d *Decoder) Resize(w, h uint16) {}
func (d *Decoder) Process([]byte) {}
func (d *Decoder) Image() *image.RGBA { return nil }
func (d *Decoder) CursorState() CursorState { return CursorState{} }
func (d *Decoder) CursorBitmap() *CursorBitmapData { return nil }
func (d *Decoder) SetCursorPosition(x, y uint16) {}
func (d *Decoder) UpdatedRegions() []image.Rectangle { return nil }
func (d *Decoder) ResetUpdatedRegions() {}
func (d *Decoder) ResizeCrop(cropX, cropY, cropW, cropH, outW, outH uint16) (*image.RGBA, error) {
func (d *Decoder) ResizeCrop(cropX, cropY, cropW, cropH, outW, outH uint16, withCursor bool) (*image.RGBA, error) {
return nil, trace.NotImplemented("the RDP decoder is not included in this build")
}
func (d *Decoder) Dimensions() (uint16, uint16) { return 0, 0 }
+117 -39
View File
@@ -14,32 +14,36 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub(crate) struct CursorBitmap {
width: u16,
height: u16,
hotspot_x: u16,
hotspot_y: u16,
data: Vec<u8>, // RGBA, 4 bytes per pixel
use ironrdp_graphics::pointer::DecodedPointer;
use std::sync::Arc;
#[derive(Default)]
pub(crate) enum CursorBitmap {
#[default]
Default,
Server(Arc<DecodedPointer>),
}
impl CursorBitmap {
pub(crate) unsafe fn write_metadata(
&self,
out_width: *mut u16,
out_height: *mut u16,
out_hotspot_x: *mut u16,
out_hotspot_y: *mut u16,
) {
unsafe {
*out_width = self.width;
*out_height = self.height;
*out_hotspot_x = self.hotspot_x;
*out_hotspot_y = self.hotspot_y;
pub(crate) fn data(&self) -> &[u8] {
match self {
CursorBitmap::Default => &DEFAULT_CURSOR_RGBA,
CursorBitmap::Server(ptr) => &ptr.bitmap_data,
}
}
pub(crate) fn data_ptr(&self) -> *const u8 {
self.data.as_ptr()
pub(crate) fn dimensions(&self) -> (u16, u16) {
match self {
CursorBitmap::Default => (DEFAULT_CURSOR_WIDTH as u16, DEFAULT_CURSOR_HEIGHT as u16),
CursorBitmap::Server(ptr) => (ptr.width, ptr.height),
}
}
pub(crate) fn hotspot(&self) -> (u16, u16) {
match self {
CursorBitmap::Default => (0, 0),
CursorBitmap::Server(ptr) => (ptr.hotspot_x, ptr.hotspot_y),
}
}
}
@@ -48,26 +52,20 @@ pub(crate) struct CursorState {
visible: bool,
x: u16,
y: u16,
bitmap: Option<CursorBitmap>,
bitmap: CursorBitmap,
}
impl CursorState {
pub(crate) fn set_bitmap(&mut self, pointer: &ironrdp_graphics::pointer::DecodedPointer) {
pub(crate) fn set_bitmap(&mut self, pointer: Arc<DecodedPointer>) {
if pointer.bitmap_data.is_empty() {
return;
}
self.bitmap = Some(CursorBitmap {
width: pointer.width,
height: pointer.height,
hotspot_x: pointer.hotspot_x,
hotspot_y: pointer.hotspot_y,
data: pointer.bitmap_data.clone(),
});
self.bitmap = CursorBitmap::Server(pointer);
}
pub(crate) fn clear_bitmap(&mut self) {
self.bitmap = None;
self.bitmap = CursorBitmap::Default;
}
pub(crate) fn move_cursor(&mut self, x: u16, y: u16) {
@@ -87,11 +85,59 @@ impl CursorState {
(self.x, self.y)
}
pub(crate) fn bitmap(&self) -> Option<&CursorBitmap> {
self.bitmap.as_ref()
pub(crate) fn bitmap(&self) -> &CursorBitmap {
&self.bitmap
}
}
// Synthetic default cursor used when the server signals `PTR_DEFAULT` (no
// bitmap supplied) or when a PointerPosition arrives before any bitmap.
const DEFAULT_CURSOR_WIDTH: usize = 12;
const DEFAULT_CURSOR_HEIGHT: usize = 17;
#[rustfmt::skip]
const DEFAULT_CURSOR_SHAPE: [u8; DEFAULT_CURSOR_WIDTH*DEFAULT_CURSOR_HEIGHT] = [
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0,
1, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0, 0,
1, 2, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0,
1, 2, 2, 2, 2, 2, 1, 0, 0, 0, 0, 0,
1, 2, 2, 2, 2, 2, 2, 1, 0, 0, 0, 0,
1, 2, 2, 2, 2, 2, 2, 2, 1, 0, 0, 0,
1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 0, 0,
1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 0,
1, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1,
1, 2, 2, 2, 1, 2, 2, 1, 0, 0, 0, 0,
1, 2, 2, 1, 0, 1, 2, 2, 1, 0, 0, 0,
1, 2, 1, 0, 0, 1, 2, 2, 1, 0, 0, 0,
1, 1, 0, 0, 0, 0, 1, 2, 1, 0, 0, 0,
1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0,
];
const DEFAULT_CURSOR_RGBA: [u8; DEFAULT_CURSOR_WIDTH * DEFAULT_CURSOR_HEIGHT * 4] = {
let mut data = [0; DEFAULT_CURSOR_WIDTH * DEFAULT_CURSOR_HEIGHT * 4];
let mut i = 0;
while i < DEFAULT_CURSOR_WIDTH * DEFAULT_CURSOR_HEIGHT {
let p = match DEFAULT_CURSOR_SHAPE[i] {
// black outline
1 => [0, 0, 0, 255],
// white body
2 => [255, 255, 255, 255],
_ => [0, 0, 0, 0],
};
data[4 * i] = p[0];
data[4 * i + 1] = p[1];
data[4 * i + 2] = p[2];
data[4 * i + 3] = p[3];
i += 1;
}
data
};
#[cfg(test)]
mod tests {
use super::*;
@@ -100,23 +146,55 @@ mod tests {
fn sample_pointer() -> DecodedPointer {
DecodedPointer {
width: 2,
height: 2,
hotspot_x: 0,
hotspot_y: 0,
bitmap_data: vec![0xFF; 2 * 2 * 4],
height: 3,
hotspot_x: 1,
hotspot_y: 2,
bitmap_data: vec![0xAB; 2 * 3 * 4],
}
}
fn assert_is_default(bitmap: &CursorBitmap) {
let (width, height) = bitmap.dimensions();
assert_eq!(width, DEFAULT_CURSOR_WIDTH as u16);
assert_eq!(height, DEFAULT_CURSOR_HEIGHT as u16);
let (hotspot_x, hotspot_y) = bitmap.hotspot();
assert_eq!(hotspot_x, 0);
assert_eq!(hotspot_y, 0);
let data = bitmap.data();
assert_eq!(data, &DEFAULT_CURSOR_RGBA[..]);
}
#[test]
fn pointer_default_clears_cached_bitmap() {
let mut state = CursorState::default();
state.set_bitmap(&sample_pointer());
assert!(state.bitmap().is_some());
assert_is_default(state.bitmap());
state.set_bitmap(Arc::new(sample_pointer()));
let cached = state.bitmap();
let (width, height) = cached.dimensions();
assert_eq!(width, 2);
assert_eq!(height, 3);
let (hotspot_x, hotspot_y) = cached.hotspot();
assert_eq!(hotspot_x, 1);
assert_eq!(hotspot_y, 2);
let data = cached.data();
assert_eq!(data, &vec![0xAB; 2 * 3 * 4][..]);
state.set_visible(true);
state.clear_bitmap();
assert!(state.bitmap().is_none());
assert_is_default(state.bitmap());
assert!(state.is_visible());
}
}
+262 -10
View File
@@ -21,13 +21,18 @@ use std::io::Error;
impl RdpDecoder {
/// Writes a CatmullRom-resized copy of the source crop region into `dst`, scaled to
/// `out_w` x `out_h`. The crop must lie within the current frame bounds.
/// `out_w` x `out_h`.
/// When `with_cursor` is true and the tracked cursor is visible, it is composited onto the
/// cropped pixels before the resize, so the cursor scales with the screen.
///
/// The crop must lie within the current frame bounds.
pub fn resize_crop_into(
&mut self,
(crop_x, crop_y): (u16, u16),
(crop_w, crop_h): (u16, u16),
(out_w, out_h): (u16, u16),
dst: &mut [u8],
with_cursor: bool,
) -> Result<(), Error> {
let bpp = self.image.pixel_format().bytes_per_pixel();
let pixel_type = match bpp {
@@ -50,18 +55,68 @@ impl RdpDecoder {
let opts = ResizeOptions::new()
.resize_alg(ResizeAlg::Convolution(FilterType::CatmullRom))
.use_alpha(false)
.crop(
f64::from(crop_x),
f64::from(crop_y),
f64::from(crop_w),
f64::from(crop_h),
.use_alpha(false);
let composite_cursor = with_cursor && self.cursor_state.is_visible();
let (src_buffer, src_pixel_w, src_pixel_h, opts) = if composite_cursor {
let (cx, cy) = self.cursor_state.position();
let cursor_bitmap = self.cursor_state.bitmap();
let bpp_usize = usize::from(bpp);
let src_stride = (src_w as usize) * bpp_usize;
let crop_row_bytes = (crop_w as usize) * bpp_usize;
let scratch_bytes = crop_row_bytes * (crop_h as usize);
if self.composite_scratch.len() < scratch_bytes {
self.composite_scratch.resize(scratch_bytes, 0);
}
let image_data = self.image.data();
let crop_x_off = (crop_x as usize) * bpp_usize;
for y in 0..(crop_h as usize) {
let src_off = ((crop_y as usize) + y) * src_stride + crop_x_off;
let dst_off = y * crop_row_bytes;
self.composite_scratch[dst_off..dst_off + crop_row_bytes]
.copy_from_slice(&image_data[src_off..src_off + crop_row_bytes]);
}
let (cursor_hotspot_x, cursor_hotspot_y) = cursor_bitmap.hotspot();
composite_over(
&mut self.composite_scratch[..scratch_bytes],
(crop_w, crop_h),
cursor_bitmap.data(),
cursor_bitmap.dimensions(),
(
i32::from(cx) - i32::from(cursor_hotspot_x) - i32::from(crop_x),
i32::from(cy) - i32::from(cursor_hotspot_y) - i32::from(crop_y),
),
);
(
&self.composite_scratch[..scratch_bytes],
crop_w,
crop_h,
opts,
)
} else {
(
self.image.data(),
src_w,
src_h,
opts.crop(
f64::from(crop_x),
f64::from(crop_y),
f64::from(crop_w),
f64::from(crop_h),
),
)
};
let src = ImageRef::new(
u32::from(src_w),
u32::from(src_h),
self.image.data(),
u32::from(src_pixel_w),
u32::from(src_pixel_h),
src_buffer,
pixel_type,
)
.map_err(Error::other)?;
@@ -81,3 +136,200 @@ impl RdpDecoder {
Ok(())
}
}
// Source-over blend of `bmp_pix` onto `dst` at (`draw_x`, `draw_y`).
// RGBA8-only: the per-pixel math assumes 4 channels with alpha at byte index 3.
fn composite_over(
dst: &mut [u8],
(dst_w, dst_h): (u16, u16),
bmp_pix: &[u8],
(bmp_w, bmp_h): (u16, u16),
(draw_x, draw_y): (i32, i32),
) {
let dw = i32::from(dst_w);
let dh = i32::from(dst_h);
let bw = i32::from(bmp_w);
let bh = i32::from(bmp_h);
let x0 = draw_x.max(0);
let y0 = draw_y.max(0);
let x1 = (draw_x + bw).min(dw);
let y1 = (draw_y + bh).min(dh);
if x0 >= x1 || y0 >= y1 {
return;
}
let dst_stride = (dw as usize) * 4;
let bmp_stride = (bw as usize) * 4;
let row_bytes = ((x1 - x0) as usize) * 4;
let dst_x_off = (x0 as usize) * 4;
let bmp_x_off = ((x0 - draw_x) as usize) * 4;
for y in y0..y1 {
let dst_start = (y as usize) * dst_stride + dst_x_off;
let bmp_start = ((y - draw_y) as usize) * bmp_stride + bmp_x_off;
let dst_row = &mut dst[dst_start..dst_start + row_bytes];
let bmp_row = &bmp_pix[bmp_start..bmp_start + row_bytes];
for (dst_px, bmp_px) in dst_row.chunks_exact_mut(4).zip(bmp_row.chunks_exact(4)) {
let sa = bmp_px[3];
if sa == 0 {
continue;
}
if sa == 255 {
dst_px.copy_from_slice(bmp_px);
continue;
}
let inv = 255 - u32::from(sa);
for c in 0..4 {
let s = u32::from(bmp_px[c]);
let d = u32::from(dst_px[c]);
// (x * 0x8081) >> 23 is a fast `x / 255` for x up to ~16 bits.
let blended = s + ((d * inv * 0x8081) >> 23);
dst_px[c] = blended.min(255) as u8;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_dst(w: u16, h: u16, fill: u8) -> Vec<u8> {
vec![fill; (w as usize) * (h as usize) * 4]
}
fn make_bmp(w: u16, h: u16, fill: [u8; 4]) -> Vec<u8> {
let mut v = Vec::with_capacity((w as usize) * (h as usize) * 4);
for _ in 0..(w as usize) * (h as usize) {
v.extend_from_slice(&fill);
}
v
}
fn pixel(buf: &[u8], stride: usize, x: usize, y: usize) -> [u8; 4] {
let off = y * stride + x * 4;
[buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]
}
#[test]
fn composite_over_transparent_source_leaves_dst_unchanged() {
let mut dst = make_dst(2, 2, 200);
let bmp = make_bmp(2, 2, [10, 20, 30, 0]);
composite_over(&mut dst, (2, 2), &bmp, (2, 2), (0, 0));
assert_eq!(dst, make_dst(2, 2, 200));
}
#[test]
fn composite_over_opaque_source_overwrites_dst() {
let mut dst = make_dst(2, 2, 200);
let bmp = make_bmp(2, 2, [10, 20, 30, 255]);
composite_over(&mut dst, (2, 2), &bmp, (2, 2), (0, 0));
for y in 0..2 {
for x in 0..2 {
assert_eq!(pixel(&dst, 8, x, y), [10, 20, 30, 255]);
}
}
}
#[test]
fn composite_over_half_alpha_blends_source_over_dst() {
// inv = 255 - 128 = 127; (200 * 127 * 0x8081) >> 23 == 99.
// RGB: s=50, d=200 → 50 + 99 = 149.
// A: s=128, d=200 → 128 + 99 = 227.
let mut dst = make_dst(1, 1, 200);
let bmp = make_bmp(1, 1, [50, 50, 50, 128]);
composite_over(&mut dst, (1, 1), &bmp, (1, 1), (0, 0));
assert_eq!(pixel(&dst, 4, 0, 0), [149, 149, 149, 227]);
}
#[test]
fn composite_over_skips_when_fully_off_screen() {
let original = make_dst(2, 2, 200);
let bmp = make_bmp(2, 2, [10, 20, 30, 255]);
for (dx, dy) in [(-2, 0), (2, 0), (0, -2), (0, 2), (-5, -5), (10, 10)] {
let mut dst = original.clone();
composite_over(&mut dst, (2, 2), &bmp, (2, 2), (dx, dy));
assert_eq!(dst, original, "expected no change at draw=({dx},{dy})");
}
}
#[test]
fn composite_over_clips_negative_draw_x() {
// 4x1 bmp at draw_x=-2, dst is 4x1: only bmp cols 2,3 land in dst cols 0,1.
let mut dst = make_dst(4, 1, 0);
let mut bmp = Vec::new();
for v in [1u8, 2, 3, 4] {
bmp.extend_from_slice(&[v, v, v, 255]);
}
composite_over(&mut dst, (4, 1), &bmp, (4, 1), (-2, 0));
assert_eq!(pixel(&dst, 16, 0, 0), [3, 3, 3, 255]);
assert_eq!(pixel(&dst, 16, 1, 0), [4, 4, 4, 255]);
assert_eq!(pixel(&dst, 16, 2, 0), [0, 0, 0, 0]);
assert_eq!(pixel(&dst, 16, 3, 0), [0, 0, 0, 0]);
}
#[test]
fn composite_over_clips_negative_draw_y() {
// 1x4 bmp at draw_y=-2, dst is 1x4: only bmp rows 2,3 land in dst rows 0,1.
let mut dst = make_dst(1, 4, 0);
let mut bmp = Vec::new();
for v in [1u8, 2, 3, 4] {
bmp.extend_from_slice(&[v, v, v, 255]);
}
composite_over(&mut dst, (1, 4), &bmp, (1, 4), (0, -2));
assert_eq!(pixel(&dst, 4, 0, 0), [3, 3, 3, 255]);
assert_eq!(pixel(&dst, 4, 0, 1), [4, 4, 4, 255]);
assert_eq!(pixel(&dst, 4, 0, 2), [0, 0, 0, 0]);
assert_eq!(pixel(&dst, 4, 0, 3), [0, 0, 0, 0]);
}
#[test]
fn composite_over_clips_right_overflow() {
// 4x1 bmp at draw_x=2, dst is 4x1: only bmp cols 0,1 land in dst cols 2,3.
let mut dst = make_dst(4, 1, 0);
let mut bmp = Vec::new();
for v in [1u8, 2, 3, 4] {
bmp.extend_from_slice(&[v, v, v, 255]);
}
composite_over(&mut dst, (4, 1), &bmp, (4, 1), (2, 0));
assert_eq!(pixel(&dst, 16, 0, 0), [0, 0, 0, 0]);
assert_eq!(pixel(&dst, 16, 1, 0), [0, 0, 0, 0]);
assert_eq!(pixel(&dst, 16, 2, 0), [1, 1, 1, 255]);
assert_eq!(pixel(&dst, 16, 3, 0), [2, 2, 2, 255]);
}
#[test]
fn composite_over_clips_bottom_overflow() {
// 1x4 bmp at draw_y=2, dst is 1x4: only bmp rows 0,1 land in dst rows 2,3.
let mut dst = make_dst(1, 4, 0);
let mut bmp = Vec::new();
for v in [1u8, 2, 3, 4] {
bmp.extend_from_slice(&[v, v, v, 255]);
}
composite_over(&mut dst, (1, 4), &bmp, (1, 4), (0, 2));
assert_eq!(pixel(&dst, 4, 0, 0), [0, 0, 0, 0]);
assert_eq!(pixel(&dst, 4, 0, 1), [0, 0, 0, 0]);
assert_eq!(pixel(&dst, 4, 0, 2), [1, 1, 1, 255]);
assert_eq!(pixel(&dst, 4, 0, 3), [2, 2, 2, 255]);
}
}
+107 -65
View File
@@ -41,6 +41,7 @@ pub struct RdpDecoder {
cursor_state: CursorState,
updated_regions: UpdatedRegions,
resizer: Resizer,
composite_scratch: Vec<u8>,
}
impl RdpDecoder {
@@ -69,6 +70,7 @@ impl RdpDecoder {
cursor_state: Default::default(),
updated_regions: Default::default(),
resizer: Resizer::new(),
composite_scratch: Vec::new(),
}
}
@@ -112,7 +114,7 @@ impl RdpDecoder {
}
UpdateKind::PointerBitmap(pointer) => {
self.cursor_state.set_visible(true);
self.cursor_state.set_bitmap(&pointer);
self.cursor_state.set_bitmap(pointer);
}
UpdateKind::PointerDefault => {
self.cursor_state.set_visible(true);
@@ -299,14 +301,18 @@ pub unsafe extern "C" fn rdp_decoder_image_data(
}
/// Writes a CatmullRom-resized copy of the source crop region into `out_buf`,
/// scaled to exactly `out_width` x `out_height`. The crop must be within the
/// current frame bounds; out-of-bounds crops are rejected.
/// scaled to exactly `out_width` x `out_height`. When `with_cursor` is non-zero
/// and the decoder's tracked cursor is visible, it is composited onto the full
/// source frame before the crop is taken, so the cursor scales with the screen.
/// The crop must lie within the current frame bounds; out-of-bounds crops are
/// rejected.
///
/// # Safety
///
/// - `ptr` must be a valid pointer previously returned by `rdp_decoder_new`.
/// - `out_buf` must point to `out_buf_len` writable bytes.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn rdp_decoder_resize_crop(
ptr: *mut RdpDecoder,
crop_x: u16,
@@ -317,6 +323,7 @@ pub unsafe extern "C" fn rdp_decoder_resize_crop(
out_height: u16,
out_buf: *mut u8,
out_buf_len: usize,
with_cursor: u8,
) -> bool {
if ptr.is_null()
|| out_buf.is_null()
@@ -328,6 +335,8 @@ pub unsafe extern "C" fn rdp_decoder_resize_crop(
return false;
}
let with_cursor = with_cursor != 0;
catch_unwind(AssertUnwindSafe(move || unsafe {
let decoder = &mut *ptr;
let needed =
@@ -343,12 +352,54 @@ pub unsafe extern "C" fn rdp_decoder_resize_crop(
(crop_w, crop_h),
(out_width, out_height),
dst,
with_cursor,
)
.is_ok()
}))
.unwrap_or(false)
}
/// Override the decoder's tracked cursor position. Useful for cursor updates
/// that arrive outside of the RDP fast-path stream (e.g., Teleport TDPB
/// MouseMove events). Does not change visibility — visibility is still driven
/// by RDP fast-path pointer updates.
///
/// # Safety
///
/// - `ptr` must be a valid pointer previously returned by `rdp_decoder_new`.
#[no_mangle]
pub unsafe extern "C" fn rdp_decoder_set_cursor_position(ptr: *mut RdpDecoder, x: u16, y: u16) {
if ptr.is_null() {
return;
}
let _ = catch_unwind(AssertUnwindSafe(move || unsafe {
let decoder = &mut *ptr;
decoder.cursor_state.move_cursor(x, y);
}));
}
/// Returns an FNV-1a 64-bit digest of pixels sampled on a fixed grid from the
/// current frame buffer. `sample_count` controls the per-axis sample density.
/// Returns 0 on null pointer or empty frame; the cursor is not composited so
/// callers can match a non-cursor Go-side sampler exactly.
///
/// # Safety
///
/// - `ptr` must be a valid pointer previously returned by `rdp_decoder_new`.
#[no_mangle]
pub unsafe extern "C" fn rdp_decoder_sample_hash(ptr: *mut RdpDecoder, sample_count: u16) -> u64 {
if ptr.is_null() {
return 0;
}
catch_unwind(AssertUnwindSafe(move || unsafe {
let decoder = &*ptr;
decoder.sample_hash(sample_count)
}))
.unwrap_or(0)
}
/// Returns the current frame dimensions via out-params. Sets both to 0 on
/// failure (null pointer, panic).
///
@@ -400,27 +451,6 @@ pub unsafe extern "C" fn rdp_decoder_bytes_per_pixel(ptr: *mut RdpDecoder) -> u8
.unwrap_or(0)
}
/// Returns an FNV-1a 64-bit digest of pixels sampled on a fixed grid from the
/// current frame buffer. `sample_count` controls the per-axis sample density.
/// Returns 0 on null pointer or empty frame; the cursor is not composited so
/// callers can match a non-cursor Go-side sampler exactly.
///
/// # Safety
///
/// - `ptr` must be a valid pointer previously returned by `rdp_decoder_new`.
#[no_mangle]
pub unsafe extern "C" fn rdp_decoder_sample_hash(ptr: *mut RdpDecoder, sample_count: u16) -> u64 {
if ptr.is_null() {
return 0;
}
catch_unwind(AssertUnwindSafe(move || unsafe {
let decoder = &*ptr;
decoder.sample_hash(sample_count)
}))
.unwrap_or(0)
}
/// Returns the current cursor position and visibility state.
///
/// # Safety
@@ -448,42 +478,6 @@ pub unsafe extern "C" fn rdp_decoder_cursor_state(
}));
}
/// Returns a pointer to the cursor bitmap data and its dimensions via out-params.
/// Returns null if no cursor bitmap is available. The returned pointer is valid
/// as long as the decoder is alive and no new PointerBitmap update is processed.
///
/// # Safety
///
/// - `ptr` must be a valid pointer previously returned by `rdp_decoder_new`.
/// - All out-params must be valid, non-null pointers.
#[no_mangle]
pub unsafe extern "C" fn rdp_decoder_cursor_bitmap(
ptr: *mut RdpDecoder,
out_width: *mut u16,
out_height: *mut u16,
out_hotspot_x: *mut u16,
out_hotspot_y: *mut u16,
) -> *const u8 {
if ptr.is_null()
|| out_width.is_null()
|| out_height.is_null()
|| out_hotspot_x.is_null()
|| out_hotspot_y.is_null()
{
return ptr::null();
}
catch_unwind(AssertUnwindSafe(move || unsafe {
let decoder = &*ptr;
let Some(bmp) = decoder.cursor_state.bitmap() else {
return ptr::null();
};
bmp.write_metadata(out_width, out_height, out_hotspot_x, out_hotspot_y);
bmp.data_ptr()
}))
.unwrap_or(ptr::null())
}
/// Copies update regions into the caller-provided buffer as (left, top, right, bottom)
/// u16 tuples. Returns the number of regions written. Coordinates use inclusive
/// right/bottom matching the RDP InclusiveRectangle convention.
@@ -567,7 +561,7 @@ mod tests {
let mut buf = vec![0xAAu8; 10 * 10 * 4];
let ok = unsafe {
rdp_decoder_resize_crop(ptr, 90, 0, 20, 10, 10, 10, buf.as_mut_ptr(), buf.len())
rdp_decoder_resize_crop(ptr, 90, 0, 20, 10, 10, 10, buf.as_mut_ptr(), buf.len(), 0)
};
// Crop extends past right edge → call returns without writing.
@@ -583,7 +577,7 @@ mod tests {
let mut buf = vec![0xAAu8; 10 * 10 * 4];
let ok = unsafe {
rdp_decoder_resize_crop(ptr, 0, 95, 10, 10, 10, 10, buf.as_mut_ptr(), buf.len())
rdp_decoder_resize_crop(ptr, 0, 95, 10, 10, 10, 10, buf.as_mut_ptr(), buf.len(), 0)
};
assert!(!ok);
@@ -599,12 +593,14 @@ mod tests {
unsafe {
// crop_w = 0
let ok = rdp_decoder_resize_crop(ptr, 0, 0, 0, 10, 10, 10, buf.as_mut_ptr(), buf.len());
let ok =
rdp_decoder_resize_crop(ptr, 0, 0, 0, 10, 10, 10, buf.as_mut_ptr(), buf.len(), 0);
assert!(!ok);
assert!(buf.iter().all(|&b| b == 0xAA));
// crop_h = 0
let ok = rdp_decoder_resize_crop(ptr, 0, 0, 10, 0, 10, 10, buf.as_mut_ptr(), buf.len());
let ok =
rdp_decoder_resize_crop(ptr, 0, 0, 10, 0, 10, 10, buf.as_mut_ptr(), buf.len(), 0);
assert!(!ok);
assert!(buf.iter().all(|&b| b == 0xAA));
@@ -619,7 +615,7 @@ mod tests {
// Exactly fills the right edge: 90 + 10 == 100.
let ok = unsafe {
rdp_decoder_resize_crop(ptr, 90, 90, 10, 10, 10, 10, buf.as_mut_ptr(), buf.len())
rdp_decoder_resize_crop(ptr, 90, 90, 10, 10, 10, 10, buf.as_mut_ptr(), buf.len(), 0)
};
assert!(ok);
@@ -643,6 +639,9 @@ mod tests {
#[test]
fn sample_hash_matches_fnv1a_reference() {
// 1x1 RGBA = single 4-byte tuple; sampler walks exactly that pixel.
// Reference value computed by hand against FNV-1a 64-bit (offset
// 0xcbf29ce484222325, prime 0x100000001b3), processing bytes 1,2,3,4.
let mut h = FNV_OFFSET_BASIS;
for b in [1u8, 2, 3, 4] {
h ^= u64::from(b);
@@ -677,4 +676,47 @@ mod tests {
b[0] = 2;
assert_ne!(sample_hash(&a, 1, 1, 4, 64), sample_hash(&b, 1, 1, 4, 64));
}
#[test]
fn resize_crop_with_cursor_accepts_in_bounds_crop() {
let ptr = make_decoder(100, 100);
let mut buf = vec![0xAAu8; 10 * 10 * 4];
unsafe {
(*ptr).cursor_state.set_visible(true);
rdp_decoder_set_cursor_position(ptr, 50, 50);
}
let ok = unsafe {
rdp_decoder_resize_crop(ptr, 0, 0, 100, 100, 10, 10, buf.as_mut_ptr(), buf.len(), 1)
};
assert!(ok);
assert!(
buf.iter().any(|&b| b != 0xAA),
"expected buffer to be written"
);
unsafe { rdp_decoder_free(ptr) };
}
#[test]
fn resize_crop_with_cursor_rejects_out_of_bounds_crop() {
let ptr = make_decoder(100, 100);
let mut buf = vec![0xAAu8; 10 * 10 * 4];
unsafe {
(*ptr).cursor_state.set_visible(true);
rdp_decoder_set_cursor_position(ptr, 50, 50);
}
let ok = unsafe {
rdp_decoder_resize_crop(ptr, 95, 0, 10, 10, 10, 10, buf.as_mut_ptr(), buf.len(), 1)
};
assert!(!ok);
assert!(buf.iter().all(|&b| b == 0xAA));
unsafe { rdp_decoder_free(ptr) };
}
}
+8 -45
View File
@@ -22,7 +22,6 @@ import (
"bytes"
"encoding/binary"
"image"
"image/draw"
"io"
"math"
@@ -58,9 +57,6 @@ const (
// Subsequent FastPathPDU messages are fed to the decoder, which maintains the screen framebuffer and cursor state.
type RDPState struct {
decoder *decoder.Decoder
mouseX, mouseY uint16
hasMouse bool
}
// New creates a new RDPState.
@@ -103,14 +99,15 @@ func (s *RDPState) Image() *image.RGBA {
}
// ResizeCrop returns the source crop region of the current screen image scaled to exactly outWidth x outHeight using
// high-quality CatmullRom convolution. The crop must lie within the current frame bounds. Returns an error if the
// decoder has not been initialized or the resize fails.
func (s *RDPState) ResizeCrop(cropX, cropY, cropW, cropH, outWidth, outHeight uint16) (*image.RGBA, error) {
// high-quality CatmullRom convolution. The crop must lie within the current frame bounds. When withCursor is true and
// the decoder's tracked cursor is visible, it is composited onto the source frame before the crop, so the cursor
// scales with the screen. Returns an error if the decoder has not been initialized or the resize fails.
func (s *RDPState) ResizeCrop(cropX, cropY, cropW, cropH, outWidth, outHeight uint16, withCursor bool) (*image.RGBA, error) {
if s.decoder == nil {
return nil, trace.BadParameter("rdp state has no image")
}
return s.decoder.ResizeCrop(cropX, cropY, cropW, cropH, outWidth, outHeight)
return s.decoder.ResizeCrop(cropX, cropY, cropW, cropH, outWidth, outHeight, withCursor)
}
// Dimensions returns the current screen width and height in pixels. Returns (0, 0) if the decoder has not been
@@ -141,40 +138,6 @@ func (s *RDPState) Release() {
}
}
// ImageWithCursor returns the current screen image with the cursor composited at its current position, along with the
// cursor state.
// When MouseMove TDPB messages have set a position, that overrides the Rust decoder's internal cursor position for compositing.
func (s *RDPState) ImageWithCursor() (*image.RGBA, decoder.CursorState) {
if s.decoder == nil {
return nil, decoder.CursorState{}
}
img := s.decoder.Image()
cs := s.CursorState()
if img == nil || !cs.Visible {
return img, cs
}
bmp := s.decoder.CursorBitmap()
if bmp == nil {
return img, cs
}
cursorX, cursorY := cs.X, cs.Y
if s.hasMouse {
cursorX, cursorY = s.mouseX, s.mouseY
}
drawX := int(cursorX) - bmp.HotspotX
drawY := int(cursorY) - bmp.HotspotY
cb := bmp.Image.Bounds()
dstRect := image.Rect(drawX, drawY, drawX+cb.Dx(), drawY+cb.Dy())
draw.Draw(img, dstRect, bmp.Image, image.Point{}, draw.Over)
return img, cs
}
// UpdatedRegions returns the individual screen regions updated since the last call to ResetUpdatedRegions.
func (s *RDPState) UpdatedRegions() []image.Rectangle {
if s.decoder == nil {
@@ -347,9 +310,9 @@ func (s *RDPState) handleMouseMove(msg *tdpbv1.MouseMove) error {
return trace.BadParameter("mouse coordinates out of range: (%d, %d)", msg.GetX(), msg.GetY())
}
s.mouseX = uint16(msg.GetX())
s.mouseY = uint16(msg.GetY())
s.hasMouse = true
// MouseMove may arrive before ServerHello initializes the decoder, in which case there's nowhere to record
// the position. SetCursorPosition is a no-op when the decoder is nil, so drop the update silently.
s.decoder.SetCursorPosition(uint16(msg.GetX()), uint16(msg.GetY()))
return nil
}
@@ -98,9 +98,9 @@ func TestProperty_RDPState_AfterReleaseNeverPanics(t *testing.T) {
_ = s.UpdatedRegions()
s.ResetUpdatedRegions()
img, cs := s.ImageWithCursor()
img, err := s.ResizeCrop(0, 0, 1, 1, 1, 1, true)
_ = img
_ = cs
_ = err
})
})
}
@@ -69,7 +69,7 @@ func TestResizeCrop_BeforeServerHello(t *testing.T) {
s := New()
t.Cleanup(s.Release)
img, err := s.ResizeCrop(0, 0, 100, 100, 50, 50)
img, err := s.ResizeCrop(0, 0, 100, 100, 50, 50, false)
require.Error(t, err)
require.Nil(t, img)
}
@@ -97,7 +97,7 @@ func TestResizeCrop_ReturnsCorrectDimensions(t *testing.T) {
t.Cleanup(s.Release)
require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 800, 600)))
img, err := s.ResizeCrop(tc.cropX, tc.cropY, tc.cropW, tc.cropH, tc.outW, tc.outH)
img, err := s.ResizeCrop(tc.cropX, tc.cropY, tc.cropW, tc.cropH, tc.outW, tc.outH, false)
require.NoError(t, err)
require.NotNil(t, img)
require.Equal(t, image.Rect(0, 0, int(tc.outW), int(tc.outH)), img.Bounds())
@@ -117,7 +117,7 @@ func TestResizeCrop_PreservesSolidColor(t *testing.T) {
sendPDU(t, s, rdpstatetest.BuildBitmapPDU(0, 0, 100, 100, rdpstatetest.RGB565Red))
// Crop a 10x10 region from the middle and scale up; every output pixel must still be red.
img, err := s.ResizeCrop(45, 45, 10, 10, 50, 50)
img, err := s.ResizeCrop(45, 45, 10, 10, 50, 50, false)
require.NoError(t, err)
require.NotNil(t, img)
require.Equal(t, image.Rect(0, 0, 50, 50), img.Bounds())
@@ -155,7 +155,7 @@ func TestResizeCrop_RejectsInvalidInputs(t *testing.T) {
t.Cleanup(s.Release)
require.NoError(t, s.HandleMessage(encodeTDPBServerHello(t, 100, 100)))
img, err := s.ResizeCrop(tc.cropX, tc.cropY, tc.cropW, tc.cropH, tc.outW, tc.outH)
img, err := s.ResizeCrop(tc.cropX, tc.cropY, tc.cropW, tc.cropH, tc.outW, tc.outH, false)
require.Error(t, err)
require.Nil(t, img)
})
+3 -3
View File
@@ -109,10 +109,10 @@ func TestMouseMove_Legacy(t *testing.T) {
evt, err := rdpstatetest.LegacyMouseMove(123, 456)
require.NoError(t, err)
// Position updates flow into the decoder via SetCursorPosition; this build uses the nop
// decoder so we just verify the message parses and dispatches without error. End-to-end
// position propagation is covered by the Rust FFI tests.
require.NoError(t, s.HandleMessage(evt))
require.True(t, s.hasMouse)
require.Equal(t, uint16(123), s.mouseX)
require.Equal(t, uint16(456), s.mouseY)
}
func TestMouseMove_LegacyTruncated(t *testing.T) {