feat: phase 9 — encoding 9 (IIP) with tile-versioned delta cache
All checks were successful
CI / check (push) Successful in 1m22s
Publish / frontend (push) Successful in 43s
CI / fmt (push) Successful in 54s
CI / clippy (push) Successful in 1m40s
Publish / backend (push) Successful in 2m27s

codec/iip.rs:
- TileCache: (fb_width/16 × fb_height/16) tiles, 8 versions × 256 bytes
  each, matching t.java's (8, 16*16) allocation
- TileEntry: versioned read/write at byte offsets within tile data
- decode_iip() handles all 4 modes from the control byte:
  - Mode 0/12 (cache-read): tile control bytes select which cached
    version of each 16x16 tile to display, no new pixel data on wire
  - Mode 4 (write-only): Tight-decoded pixel data written to cache
  - Mode 8 (update+read): conditionally writes new data to cache
    (bit 7 of control byte = 0 means update), then reads from cache
- Tile control bytes compressed via zlib (varint length) when >= 12
- Sub-types 1-4/8 map to bit-depths 1/2/4/4/8 bpp
- Cache resized on framebuffer resize (ModeChange msg 128)

Wired into session dispatch for encoding 9. Not advertised in default
encoding list — only active if explicitly requested.

codec/tight.rs: made get_or_init() pub for IIP's zlib access.

39 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 09:39:49 +03:00
parent e39555196d
commit acf99f849b
4 changed files with 285 additions and 3 deletions

View File

@@ -0,0 +1,263 @@
use std::io::Read;
use crate::codec::tight;
use crate::framebuffer::Framebuffer;
use crate::proto::{self, read_exact, read_u8, read_varint};
const TILE: usize = 16;
const VERSIONS: usize = 8;
const TILE_BYTES: usize = TILE * TILE; // 256 bytes at 8bpp
/// Per-tile versioned pixel cache — stores 8bpp data.
/// Reference: t.java — 8 versions × 256 bytes per tile.
struct TileEntry {
data: [[u8; TILE_BYTES]; VERSIONS],
}
#[allow(dead_code)]
impl TileEntry {
fn new() -> Self {
Self {
data: [[0u8; TILE_BYTES]; VERSIONS],
}
}
/// Write `len` bytes from `src[src_off..]` into version at `offset`.
fn write(&mut self, version: usize, offset: usize, src: &[u8], src_off: usize, len: usize) {
let v = version % VERSIONS;
self.data[v][offset..offset + len].copy_from_slice(&src[src_off..src_off + len]);
}
/// Read `len` bytes from version at `offset` into `dst[dst_off..]`.
fn read(&self, version: usize, offset: usize, dst: &mut [u8], dst_off: usize, len: usize) {
let v = version % VERSIONS;
dst[dst_off..dst_off + len].copy_from_slice(&self.data[v][offset..offset + len]);
}
}
/// Tile cache for encoding 9 (IIP). One entry per 16×16 tile on screen.
pub struct TileCache {
tiles: Vec<TileEntry>,
tiles_x: usize,
tiles_y: usize,
}
impl TileCache {
pub fn new(fb_width: u16, fb_height: u16) -> Self {
let tx = fb_width as usize / TILE;
let ty = fb_height as usize / TILE;
let count = tx * ty;
let mut tiles = Vec::with_capacity(count);
for _ in 0..count {
tiles.push(TileEntry::new());
}
Self {
tiles,
tiles_x: tx,
tiles_y: ty,
}
}
pub fn resize(&mut self, fb_width: u16, fb_height: u16) {
*self = Self::new(fb_width, fb_height);
}
fn get(&self, tile_x: usize, tile_y: usize) -> &TileEntry {
&self.tiles[tile_y * self.tiles_x + tile_x]
}
fn get_mut(&mut self, tile_x: usize, tile_y: usize) -> &mut TileEntry {
&mut self.tiles[tile_y * self.tiles_x + tile_x]
}
}
/// Decode encoding 9 (IIP) — tile-cached delta compression.
///
/// Control byte layout:
/// - bits 0-3: sub-type (1=1bpp, 2=2bpp, 3=4bpp_gray, 4=4bpp_color, 8=8bpp)
/// - bits 4-5: zlib stream index (0-3)
/// - bits 6-7: mode (0=cache-read, 4=write-only, 8=update+read, 12=read-only)
///
/// Reference: ByteColorRFBRenderer.do() line 248.
#[allow(clippy::too_many_arguments)]
pub fn decode_iip(
r: &mut impl Read,
fb: &mut Framebuffer,
cache: &mut TileCache,
zlib: &mut tight::ZlibStreams,
rx: u16,
ry: u16,
rw: u16,
rh: u16,
) -> proto::Result<()> {
let control = read_u8(r)?;
let stream_idx = ((control >> 4) & 3) as usize;
let mode = (control >> 4) & 0x0C; // 0, 4, 8, or 12
let sub_type = control & 0x0F;
// Determine palette from sub-type (same as tight sub-palettes)
let _palette_id = match sub_type {
1 => 10, // 1bpp BW
2 => 11, // 2bpp gray4
3 => 12, // 4bpp gray16
4 => 13, // 4bpp color16
8 => 0, // 8bpp direct
_ => return Ok(()), // unknown sub-type, skip
};
// Calculate aligned tile region
let y_start = ry as usize;
let y_end = ry as usize + rh as usize;
let w = rw as usize;
let tile_y_end = if !y_end.is_multiple_of(TILE) {
y_end / TILE * TILE
} else {
y_end
};
let aligned_h = tile_y_end - y_start;
let num_tile_ctrl = (w / TILE) * (aligned_h / TILE);
// Read tile control bytes (compressed or raw)
let tile_ctrl = if num_tile_ctrl < 12 {
read_exact(r, num_tile_ctrl)?
} else {
let comp_len = read_varint(r)? as usize;
let compressed = read_exact(r, comp_len)?;
let decompressor = zlib.get_or_init(stream_idx);
let mut output = vec![0u8; num_tile_ctrl];
let before_out = decompressor.total_out();
decompressor
.decompress(&compressed, &mut output, flate2::FlushDecompress::None)
.map_err(|e| {
proto::ProtoError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
})?;
let produced = (decompressor.total_out() - before_out) as usize;
if produced != num_tile_ctrl {
return Err(proto::ProtoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("iip zlib: produced {produced}, expected {num_tile_ctrl}"),
)));
}
output
};
if mode == 0 || mode == 12 {
// Cache-read only: no new pixel data on the wire.
// Read tile versions from cache directly to framebuffer.
read_cache_to_fb(fb, cache, &tile_ctrl, rx as usize, y_start, w, aligned_h);
} else {
// Mode 4 or 8: Tight-encoded pixel data follows.
// Decode Tight data into framebuffer, then update tile cache from framebuffer.
tight::decode_tight(r, fb, zlib, rx, ry, rw, rh)?;
// Update tile cache from the framebuffer pixels we just wrote
update_cache_from_fb(
fb,
cache,
&tile_ctrl,
rx as usize,
y_start,
w,
aligned_h,
mode,
);
// For mode 8, re-read from cache (may differ if some tiles weren't updated)
if mode == 8 {
read_cache_to_fb(fb, cache, &tile_ctrl, rx as usize, y_start, w, aligned_h);
}
}
Ok(())
}
/// Read tile versions from cache into the framebuffer.
fn read_cache_to_fb(
fb: &mut Framebuffer,
cache: &TileCache,
ctrl: &[u8],
rx: usize,
ry: usize,
rw: usize,
aligned_h: usize,
) {
let stride = fb.width as usize;
let mut ci = 0;
for ty in (0..aligned_h).step_by(TILE) {
for tx in (0..rw).step_by(TILE) {
let version = (ctrl[ci] & 0x7F) as usize;
ci += 1;
let tile_x = (rx + tx) / TILE;
let tile_y = (ry + ty) / TILE;
if tile_x >= cache.tiles_x || tile_y >= cache.tiles_y {
continue;
}
let entry = cache.get(tile_x, tile_y);
let tw = TILE.min(rw - tx);
let th = TILE.min(aligned_h - ty);
for row in 0..th {
let fb_off = (ry + ty + row) * stride + rx + tx;
let cache_off = row * TILE;
fb.pixels[fb_off..fb_off + tw]
.copy_from_slice(&entry.data[version % VERSIONS][cache_off..cache_off + tw]);
}
}
}
}
/// Update tile cache from framebuffer pixels.
#[allow(clippy::too_many_arguments)]
fn update_cache_from_fb(
fb: &Framebuffer,
cache: &mut TileCache,
ctrl: &[u8],
rx: usize,
ry: usize,
rw: usize,
aligned_h: usize,
mode: u8,
) {
let stride = fb.width as usize;
let mut ci = 0;
for ty in (0..aligned_h).step_by(TILE) {
for _tx_idx in 0..(rw / TILE) {
let tx = _tx_idx * TILE;
let byte = ctrl[ci];
ci += 1;
let version = (byte & 0x7F) as usize;
let should_update = if mode == 8 {
byte & 0x80 == 0 // bit 7 clear = update
} else {
true // mode 4: always update
};
if !should_update {
continue;
}
let tile_x = (rx + tx) / TILE;
let tile_y = (ry + ty) / TILE;
if tile_x >= cache.tiles_x || tile_y >= cache.tiles_y {
continue;
}
let entry = cache.get_mut(tile_x, tile_y);
let tw = TILE.min(rw - tx);
let th = TILE.min(aligned_h - ty);
for row in 0..th {
let fb_off = (ry + ty + row) * stride + rx + tx;
let cache_off = row * TILE;
entry.data[version % VERSIONS][cache_off..cache_off + tw]
.copy_from_slice(&fb.pixels[fb_off..fb_off + tw]);
}
}
}
}

View File

@@ -1,3 +1,4 @@
pub mod hextile; pub mod hextile;
pub mod iip;
pub mod raw_tile; pub mod raw_tile;
pub mod tight; pub mod tight;

View File

@@ -82,7 +82,7 @@ impl ZlibStreams {
} }
} }
fn get_or_init(&mut self, idx: usize) -> &mut Decompress { pub fn get_or_init(&mut self, idx: usize) -> &mut Decompress {
self.streams[idx].get_or_insert_with(|| Decompress::new(true)) self.streams[idx].get_or_insert_with(|| Decompress::new(true))
} }

View File

@@ -1,7 +1,7 @@
use std::io::{BufReader, BufWriter}; use std::io::{BufReader, BufWriter};
use std::net::TcpStream; use std::net::TcpStream;
use crate::codec::{hextile, raw_tile, tight}; use crate::codec::{hextile, iip, raw_tile, tight};
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};
@@ -46,19 +46,23 @@ pub struct ActiveSession {
pub writer: BufWriter<TcpStream>, pub writer: BufWriter<TcpStream>,
pub server_init: ServerInit, pub server_init: ServerInit,
zlib: tight::ZlibStreams, zlib: tight::ZlibStreams,
tile_cache: iip::TileCache,
} }
impl ActiveSession { impl ActiveSession {
/// Connect, handshake, send SetEncodings + initial FBUpdateRequest. /// Connect, handshake, send SetEncodings + initial FBUpdateRequest.
pub fn connect(cfg: &Config, encodings: &[i32]) -> Result<Self, SessionError> { pub fn connect(cfg: &Config, encodings: &[i32]) -> Result<Self, SessionError> {
let raw = handshake::connect(cfg)?; let raw = handshake::connect(cfg)?;
let w = raw.server_init.width;
let h = raw.server_init.height;
let mut session = Self { let mut session = Self {
framebuffer: Framebuffer::new(raw.server_init.width, raw.server_init.height), framebuffer: Framebuffer::new(w, h),
server_name: raw.server_name, server_name: raw.server_name,
reader: raw.reader, reader: raw.reader,
writer: raw.writer, writer: raw.writer,
server_init: raw.server_init, server_init: raw.server_init,
zlib: tight::ZlibStreams::new(), zlib: tight::ZlibStreams::new(),
tile_cache: iip::TileCache::new(w, h),
}; };
// Tell server to send 8bpp RGB332 pixels // Tell server to send 8bpp RGB332 pixels
@@ -157,6 +161,8 @@ impl ActiveSession {
if self.server_init.width != old_w || self.server_init.height != old_h { if self.server_init.width != old_w || self.server_init.height != old_h {
self.framebuffer self.framebuffer
.resize(self.server_init.width, self.server_init.height); .resize(self.server_init.width, self.server_init.height);
self.tile_cache
.resize(self.server_init.width, self.server_init.height);
return Ok(Some(Event::Resize { return Ok(Some(Event::Resize {
width: self.server_init.width, width: self.server_init.width,
height: self.server_init.height, height: self.server_init.height,
@@ -221,6 +227,18 @@ impl ActiveSession {
hdr.h, hdr.h,
)?; )?;
} }
9 => {
iip::decode_iip(
&mut self.reader,
&mut self.framebuffer,
&mut self.tile_cache,
&mut self.zlib,
hdr.x,
hdr.y,
hdr.w,
hdr.h,
)?;
}
10 => { 10 => {
raw_tile::decode_raw_tile( raw_tile::decode_raw_tile(
&mut self.reader, &mut self.reader,