use bevy::prelude::*;
use bevy::render::gpu_readback::{Readback, ReadbackComplete};
use crate::{proto, write_stdout, KittyConfig, TargetState};
fn aligned_bytes_per_row(unpadded: u32) -> u32 {
let align = 256;
unpadded.div_ceil(align) * align
}
fn fnv1a(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x100_0000_01b3);
}
h
}
#[derive(Resource, Default)]
pub struct FrameState {
last_hash: u64,
scratch: Vec<u8>,
frames_emitted: u64,
frames_skipped: u64,
}
pub(crate) fn build(app: &mut App) {
app.init_resource::<FrameState>();
app.add_systems(Update, attach_readback_once);
app.add_observer(on_readback_complete);
}
fn attach_readback_once(mut commands: Commands, target: Res<TargetState>, mut done: Local<bool>) {
if *done {
return;
}
let Some(handle) = target.image.clone() else {
return; };
commands.spawn(Readback::texture(handle));
*done = true;
info!("[kitty] frame readback attached");
}
fn on_readback_complete(
trigger: On<ReadbackComplete>,
mut state: ResMut<FrameState>,
config: Res<KittyConfig>,
) {
let padded = &trigger.event().data;
let width = config.virtual_size.x;
let height = config.virtual_size.y;
let unpadded_row = (width * 4) as usize;
let padded_row = aligned_bytes_per_row(width * 4) as usize;
let expected = padded_row * height as usize;
if padded.len() < expected {
error!(
"[kitty] readback short: got {} bytes, expected >= {} ({}x{} @ stride {}) - \
dropping frame",
padded.len(),
expected,
width,
height,
padded_row
);
return;
}
let tight_len = unpadded_row * height as usize;
state.scratch.clear();
state.scratch.reserve(tight_len);
if padded_row == unpadded_row {
state.scratch.extend_from_slice(&padded[..tight_len]);
} else {
for row in 0..height as usize {
let start = row * padded_row;
state
.scratch
.extend_from_slice(&padded[start..start + unpadded_row]);
}
}
let hash = fnv1a(&state.scratch);
if hash == state.last_hash && state.frames_emitted > 0 {
state.frames_skipped += 1;
if state.frames_skipped % 60 == 1 {
debug!(
"[kitty] frame unchanged (skipped {} so far) - not re-sending",
state.frames_skipped
);
}
return;
}
state.last_hash = hash;
let mut buf = Vec::with_capacity(state.scratch.len() * 2);
proto::cursor_to(&mut buf, 1, 1);
proto::transmit_and_display_rgba(&mut buf, 1, width, height, &state.scratch);
if !write_stdout(&buf, "frame") {
return;
}
state.frames_emitted += 1;
if state.frames_emitted <= 3 || state.frames_emitted.is_multiple_of(120) {
info!(
"[kitty] emitted frame #{} ({} bytes RGBA, {} escape bytes)",
state.frames_emitted,
state.scratch.len(),
buf.len()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn row_alignment_rounds_up_to_256() {
assert_eq!(
aligned_bytes_per_row(1280),
1280,
"320px is already aligned"
);
assert_eq!(aligned_bytes_per_row(1281), 1536);
assert_eq!(aligned_bytes_per_row(1), 256);
assert_eq!(aligned_bytes_per_row(256), 256);
}
#[test]
fn a_common_resolution_actually_needs_padding() {
assert_ne!(aligned_bytes_per_row(200 * 4), 200 * 4);
}
#[test]
fn hash_distinguishes_frames_but_matches_identical_ones() {
let a = vec![1u8, 2, 3, 4];
let b = vec![1u8, 2, 3, 5];
assert_eq!(fnv1a(&a), fnv1a(&a.clone()));
assert_ne!(fnv1a(&a), fnv1a(&b));
assert_ne!(fnv1a(&[]), 0);
}
}