feat: phase 4 — Hextile decoder and recording example
codec/hextile.rs: - Full Hextile (encoding 5) decoder per ByteColorRFBRenderer.int() - Handles: Raw tiles, BackgroundSpecified, ForegroundSpecified, AnySubrects, SubrectsColoured flags - Background/foreground colors persist across tiles - 4 unit tests covering all subencoding paths framebuffer.rs: - Added fill_rect() for Hextile background/subrect fills session.rs: - Wired Hextile encoding 5 into the rect dispatch examples/record.rs: - 30-second (configurable) recording session - Saves 1 PNG per second to out/ directory - Requests encodings [5, 1, 0] (Hextile, CopyRect, Raw) - Tested against real OmniView: 10 frames in 10s, no errors 27 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
crates/ericrfb/examples/record.rs
Normal file
108
crates/ericrfb/examples/record.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::BufWriter;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use ericrfb::handshake::Config;
|
||||||
|
use ericrfb::session::{ActiveSession, Event};
|
||||||
|
|
||||||
|
fn save_png(fb: &ericrfb::framebuffer::Framebuffer, path: &Path) {
|
||||||
|
let rgba = fb.to_rgba();
|
||||||
|
let file = File::create(path).expect("cannot create PNG file");
|
||||||
|
let bw = BufWriter::new(file);
|
||||||
|
let mut encoder = png::Encoder::new(bw, fb.width as u32, fb.height as u32);
|
||||||
|
encoder.set_color(png::ColorType::Rgba);
|
||||||
|
encoder.set_depth(png::BitDepth::Eight);
|
||||||
|
let mut writer = encoder.write_header().expect("png header failed");
|
||||||
|
writer.write_image_data(&rgba).expect("png write failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
|
||||||
|
let host = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--host")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.expect("usage: --host <ip> --applet-id <token> [--port <port>] [--duration <secs>]");
|
||||||
|
|
||||||
|
let applet_id = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--applet-id")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.expect("usage: --host <ip> --applet-id <token> [--port <port>] [--duration <secs>]");
|
||||||
|
|
||||||
|
let port: u16 = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--port")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(443);
|
||||||
|
|
||||||
|
let duration_secs: u64 = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--duration")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(30);
|
||||||
|
|
||||||
|
let out_dir = Path::new("out");
|
||||||
|
fs::create_dir_all(out_dir).expect("cannot create out/");
|
||||||
|
|
||||||
|
let cfg = Config::new(host, port, applet_id);
|
||||||
|
println!("Connecting to {}:{}...", cfg.host, cfg.port);
|
||||||
|
|
||||||
|
// Request Hextile (5), CopyRect (1), Raw (0)
|
||||||
|
let mut session = ActiveSession::connect(&cfg, &[5, 1, 0]).expect("connect failed");
|
||||||
|
println!(
|
||||||
|
"Connected: {}x{}, recording for {duration_secs}s...",
|
||||||
|
session.framebuffer.width, session.framebuffer.height
|
||||||
|
);
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let duration = Duration::from_secs(duration_secs);
|
||||||
|
let mut last_save = Instant::now() - Duration::from_secs(2); // force first save
|
||||||
|
let mut frame_count = 0u32;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if start.elapsed() >= duration {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
match session.process_one() {
|
||||||
|
Ok(Some(Event::FramebufferDirty)) => {
|
||||||
|
// Save at most 1 PNG per second
|
||||||
|
if last_save.elapsed() >= Duration::from_secs(1) {
|
||||||
|
let path = out_dir.join(format!("frame_{frame_count:04}.png"));
|
||||||
|
save_png(&session.framebuffer, &path);
|
||||||
|
println!(
|
||||||
|
"[{:.1}s] saved {}",
|
||||||
|
start.elapsed().as_secs_f64(),
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
frame_count += 1;
|
||||||
|
last_save = Instant::now();
|
||||||
|
}
|
||||||
|
// Request next update
|
||||||
|
if let Err(e) = session.request_update() {
|
||||||
|
eprintln!("Error requesting update: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(Event::Resize { width, height })) => {
|
||||||
|
println!("Resized to {width}x{height}");
|
||||||
|
}
|
||||||
|
Ok(Some(Event::Debug(s))) => {
|
||||||
|
eprintln!("[debug] {s}");
|
||||||
|
}
|
||||||
|
Ok(Some(_)) | Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Done. Saved {frame_count} frames to {}/", out_dir.display());
|
||||||
|
}
|
||||||
148
crates/ericrfb/src/codec/hextile.rs
Normal file
148
crates/ericrfb/src/codec/hextile.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
use crate::framebuffer::Framebuffer;
|
||||||
|
use crate::proto::{self, read_exact, read_u8};
|
||||||
|
|
||||||
|
// Subencoding flag bits — ByteColorRFBRenderer.int(), line 192
|
||||||
|
const RAW: u8 = 1;
|
||||||
|
const BACKGROUND_SPECIFIED: u8 = 2;
|
||||||
|
const FOREGROUND_SPECIFIED: u8 = 4;
|
||||||
|
const ANY_SUBRECTS: u8 = 8;
|
||||||
|
const SUBRECTS_COLOURED: u8 = 16;
|
||||||
|
|
||||||
|
/// Decode a Hextile-encoded rectangle into the framebuffer.
|
||||||
|
///
|
||||||
|
/// The rectangle is divided into 16x16 tiles (edge tiles may be smaller).
|
||||||
|
/// Background and foreground colors persist across tiles within one call.
|
||||||
|
///
|
||||||
|
/// Reference: ByteColorRFBRenderer.int() line 169.
|
||||||
|
pub fn decode_hextile(
|
||||||
|
r: &mut impl Read,
|
||||||
|
fb: &mut Framebuffer,
|
||||||
|
rx: u16,
|
||||||
|
ry: u16,
|
||||||
|
rw: u16,
|
||||||
|
rh: u16,
|
||||||
|
) -> proto::Result<()> {
|
||||||
|
let mut bg: u8 = 0;
|
||||||
|
let mut fg: u8 = 0;
|
||||||
|
|
||||||
|
let mut ty = ry;
|
||||||
|
while ty < ry + rh {
|
||||||
|
let tile_h = (ry + rh - ty).min(16);
|
||||||
|
let mut tx = rx;
|
||||||
|
while tx < rx + rw {
|
||||||
|
let tile_w = (rx + rw - tx).min(16);
|
||||||
|
let flags = read_u8(r)?;
|
||||||
|
|
||||||
|
if flags & RAW != 0 {
|
||||||
|
// Raw tile: read tile_w * tile_h bytes
|
||||||
|
let size = tile_w as usize * tile_h as usize;
|
||||||
|
let data = read_exact(r, size)?;
|
||||||
|
fb.apply_raw(tx, ty, tile_w, tile_h, &data);
|
||||||
|
tx += 16;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags & BACKGROUND_SPECIFIED != 0 {
|
||||||
|
bg = read_u8(r)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill tile with background
|
||||||
|
fb.fill_rect(tx, ty, tile_w, tile_h, bg);
|
||||||
|
|
||||||
|
if flags & FOREGROUND_SPECIFIED != 0 {
|
||||||
|
fg = read_u8(r)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags & ANY_SUBRECTS != 0 {
|
||||||
|
let num_subrects = read_u8(r)?;
|
||||||
|
let coloured = flags & SUBRECTS_COLOURED != 0;
|
||||||
|
|
||||||
|
for _ in 0..num_subrects {
|
||||||
|
let color = if coloured { read_u8(r)? } else { fg };
|
||||||
|
let xy = read_u8(r)?;
|
||||||
|
let wh = read_u8(r)?;
|
||||||
|
let sx = (xy >> 4) as u16;
|
||||||
|
let sy = (xy & 0x0F) as u16;
|
||||||
|
let sw = ((wh >> 4) + 1) as u16;
|
||||||
|
let sh = ((wh & 0x0F) + 1) as u16;
|
||||||
|
fb.fill_rect(tx + sx, ty + sy, sw, sh, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx += 16;
|
||||||
|
}
|
||||||
|
ty += 16;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hextile_raw_tile() {
|
||||||
|
let mut fb = Framebuffer::new(16, 16);
|
||||||
|
// One 16x16 tile, Raw subencoding
|
||||||
|
let mut data = vec![RAW]; // flags
|
||||||
|
data.extend_from_slice(&[0x42u8; 256]); // 16*16 raw pixels
|
||||||
|
let mut c = Cursor::new(data);
|
||||||
|
decode_hextile(&mut c, &mut fb, 0, 0, 16, 16).unwrap();
|
||||||
|
assert_eq!(fb.pixels[0], 0x42);
|
||||||
|
assert_eq!(fb.pixels[255], 0x42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hextile_bg_fill() {
|
||||||
|
let mut fb = Framebuffer::new(16, 16);
|
||||||
|
// One tile: background=0x09, no subrects
|
||||||
|
let data = vec![BACKGROUND_SPECIFIED, 0x09];
|
||||||
|
let mut c = Cursor::new(data);
|
||||||
|
decode_hextile(&mut c, &mut fb, 0, 0, 16, 16).unwrap();
|
||||||
|
assert!(fb.pixels.iter().all(|&p| p == 0x09));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hextile_subrects_coloured() {
|
||||||
|
let mut fb = Framebuffer::new(16, 16);
|
||||||
|
// Background=0x00, 1 coloured subrect at (2,3) size 4x5 color 0xFF
|
||||||
|
let data = vec![
|
||||||
|
BACKGROUND_SPECIFIED | ANY_SUBRECTS | SUBRECTS_COLOURED,
|
||||||
|
0x00, // bg
|
||||||
|
1, // num_subrects
|
||||||
|
0xFF, // subrect color
|
||||||
|
0x23, // xy: x=2, y=3
|
||||||
|
0x34, // wh: w=3+1=4, h=4+1=5
|
||||||
|
];
|
||||||
|
let mut c = Cursor::new(data);
|
||||||
|
decode_hextile(&mut c, &mut fb, 0, 0, 16, 16).unwrap();
|
||||||
|
assert_eq!(fb.pixels[0], 0x00); // background
|
||||||
|
assert_eq!(fb.pixels[3 * 16 + 2], 0xFF); // subrect at (2,3)
|
||||||
|
assert_eq!(fb.pixels[7 * 16 + 5], 0xFF); // subrect at (5,7)
|
||||||
|
assert_eq!(fb.pixels[8 * 16 + 2], 0x00); // below subrect
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hextile_fg_subrects() {
|
||||||
|
let mut fb = Framebuffer::new(16, 16);
|
||||||
|
// Background=0x00, foreground=0xAA, 1 subrect at (0,0) size 2x2
|
||||||
|
let data = vec![
|
||||||
|
BACKGROUND_SPECIFIED | FOREGROUND_SPECIFIED | ANY_SUBRECTS,
|
||||||
|
0x00, // bg
|
||||||
|
0xAA, // fg
|
||||||
|
1, // num_subrects
|
||||||
|
0x00, // xy: x=0, y=0
|
||||||
|
0x11, // wh: w=1+1=2, h=1+1=2
|
||||||
|
];
|
||||||
|
let mut c = Cursor::new(data);
|
||||||
|
decode_hextile(&mut c, &mut fb, 0, 0, 16, 16).unwrap();
|
||||||
|
assert_eq!(fb.pixels[0], 0xAA);
|
||||||
|
assert_eq!(fb.pixels[1], 0xAA);
|
||||||
|
assert_eq!(fb.pixels[16], 0xAA);
|
||||||
|
assert_eq!(fb.pixels[17], 0xAA);
|
||||||
|
assert_eq!(fb.pixels[2], 0x00);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
crates/ericrfb/src/codec/mod.rs
Normal file
1
crates/ericrfb/src/codec/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod hextile;
|
||||||
@@ -40,6 +40,15 @@ impl Framebuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fill a rectangle with a single 8bpp color value.
|
||||||
|
pub fn fill_rect(&mut self, x: u16, y: u16, w: u16, h: u16, color: u8) {
|
||||||
|
let stride = self.width as usize;
|
||||||
|
for row in 0..h as usize {
|
||||||
|
let offset = (y as usize + row) * stride + x as usize;
|
||||||
|
self.pixels[offset..offset + w as usize].fill(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Copy a rectangle within the framebuffer (CopyRect encoding).
|
/// Copy a rectangle within the framebuffer (CopyRect encoding).
|
||||||
/// Handles overlapping regions correctly.
|
/// Handles overlapping regions correctly.
|
||||||
pub fn copy_rect(&mut self, src_x: u16, src_y: u16, dst_x: u16, dst_y: u16, w: u16, h: u16) {
|
pub fn copy_rect(&mut self, src_x: u16, src_y: u16, dst_x: u16, dst_y: u16, w: u16, h: u16) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod codec;
|
||||||
pub mod framebuffer;
|
pub mod framebuffer;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod msg;
|
pub mod msg;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::io::{BufReader, BufWriter};
|
use std::io::{BufReader, BufWriter};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::codec::hextile;
|
||||||
use crate::framebuffer::Framebuffer;
|
use crate::framebuffer::Framebuffer;
|
||||||
use crate::handshake::{self, Config, ServerInit};
|
use crate::handshake::{self, Config, ServerInit};
|
||||||
use crate::msg::{self, ServerMsg};
|
use crate::msg::{self, ServerMsg};
|
||||||
@@ -196,6 +197,17 @@ impl ActiveSession {
|
|||||||
self.framebuffer
|
self.framebuffer
|
||||||
.copy_rect(src_x, src_y, hdr.x, hdr.y, hdr.w, hdr.h);
|
.copy_rect(src_x, src_y, hdr.x, hdr.y, hdr.w, hdr.h);
|
||||||
}
|
}
|
||||||
|
5 => {
|
||||||
|
// Hextile
|
||||||
|
hextile::decode_hextile(
|
||||||
|
&mut self.reader,
|
||||||
|
&mut self.framebuffer,
|
||||||
|
hdr.x,
|
||||||
|
hdr.y,
|
||||||
|
hdr.w,
|
||||||
|
hdr.h,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
return Err(SessionError::UnsupportedEncoding(other));
|
return Err(SessionError::UnsupportedEncoding(other));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user