use rand::{Rng, SeedableRng, rngs::SmallRng};
use super::{
env::AtariEnv,
protocol::{OBS_CHANNELS, OBS_HEIGHT, OBS_LEN, OBS_WIDTH},
};
use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
#[derive(Debug, Clone)]
pub struct PreprocessConfig {
pub frame_skip: usize,
pub frame_stack: usize,
pub sticky_p: f32,
pub life_loss_termination: bool,
pub screen_size: usize,
}
impl Default for PreprocessConfig {
fn default() -> Self {
Self {
frame_skip: 4,
frame_stack: 4,
sticky_p: 0.25,
life_loss_termination: true,
screen_size: 84,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PreprocessState {
pub inner: Vec<u8>,
pub stack: Vec<f32>,
pub prev_raw: Vec<f32>,
pub last_action: i64,
pub last_lives: u32,
pub terminated: bool,
pub sticky_rolls: u64,
}
pub struct AtariPreprocess {
inner: AtariEnv,
config: PreprocessConfig,
seed: u64,
rng: SmallRng,
sticky_rolls: u64,
stack: Vec<Vec<f32>>,
prev_raw: Vec<f32>,
last_action: i64,
last_reward: f32,
last_lives: u32,
terminated: bool,
truncated: bool,
}
impl AtariPreprocess {
pub fn new(game_id: &str, seed: u64) -> Result<Self, super::AtariEnvError> {
Self::with_config(game_id, seed, PreprocessConfig::default())
}
pub fn with_config(
game_id: &str,
seed: u64,
config: PreprocessConfig,
) -> Result<Self, super::AtariEnvError> {
let inner = AtariEnv::new(game_id, seed)?;
Ok(Self::wrap(inner, seed, config))
}
#[must_use]
pub fn wrap(inner: AtariEnv, seed: u64, config: PreprocessConfig) -> Self {
let frame_len = config.screen_size * config.screen_size;
let stack = vec![vec![0.0f32; frame_len]; config.frame_stack];
Self {
inner,
config,
seed,
rng: SmallRng::seed_from_u64(seed),
sticky_rolls: 0,
stack,
prev_raw: vec![0.0f32; OBS_LEN],
last_action: 0,
last_reward: 0.0,
last_lives: 0,
terminated: false,
truncated: false,
}
}
#[must_use]
pub fn inner(&self) -> &AtariEnv {
&self.inner
}
#[must_use]
pub fn config(&self) -> &PreprocessConfig {
&self.config
}
fn roll_sticky(&mut self) -> bool {
let v: f32 = self.rng.random();
self.sticky_rolls += 1;
v < self.config.sticky_p
}
fn grayscale(raw: &[f32]) -> Vec<f32> {
let mut out = vec![0.0f32; OBS_HEIGHT * OBS_WIDTH];
for (i, px) in out.iter_mut().enumerate() {
let base = i * OBS_CHANNELS;
let r = raw[base];
let g = raw[base + 1];
let b = raw[base + 2];
*px = 0.299 * r + 0.587 * g + 0.114 * b;
}
out
}
fn max_pool(a: &[f32], b: &[f32]) -> Vec<f32> {
a.iter().zip(b.iter()).map(|(&x, &y)| x.max(y)).collect()
}
fn downsample(src: &[f32], src_h: usize, src_w: usize, dst: usize) -> Vec<f32> {
let mut out = vec![0.0f32; dst * dst];
let scale_y = src_h as f32 / dst as f32;
let scale_x = src_w as f32 / dst as f32;
for oy in 0..dst {
let sy = ((oy as f32 + 0.5) * scale_y - 0.5).clamp(0.0, (src_h - 1) as f32);
let y0 = sy.floor() as usize;
let y1 = (y0 + 1).min(src_h - 1);
let wy = sy - y0 as f32;
for ox in 0..dst {
let sx = ((ox as f32 + 0.5) * scale_x - 0.5).clamp(0.0, (src_w - 1) as f32);
let x0 = sx.floor() as usize;
let x1 = (x0 + 1).min(src_w - 1);
let wx = sx - x0 as f32;
let p00 = src[y0 * src_w + x0];
let p01 = src[y0 * src_w + x1];
let p10 = src[y1 * src_w + x0];
let p11 = src[y1 * src_w + x1];
let top = p00 * (1.0 - wx) + p01 * wx;
let bot = p10 * (1.0 - wx) + p11 * wx;
out[oy * dst + ox] = top * (1.0 - wy) + bot * wy;
}
}
out
}
fn preprocess_frame(&self, raw: &[f32]) -> Vec<f32> {
let gray = Self::grayscale(raw);
Self::downsample(&gray, OBS_HEIGHT, OBS_WIDTH, self.config.screen_size)
}
fn push_frame(&mut self, frame: Vec<f32>) {
self.stack.remove(0);
self.stack.push(frame);
}
fn fill_stack(&mut self, frame: &[f32]) {
for slot in &mut self.stack {
slot.clear();
slot.extend_from_slice(frame);
}
}
fn flat_observation(&self) -> Vec<f32> {
let mut out = Vec::with_capacity(self.config.frame_stack * self.frame_len());
for slot in &self.stack {
out.extend(slot.iter().map(|&v| v / 255.0));
}
out
}
fn frame_len(&self) -> usize {
self.config.screen_size * self.config.screen_size
}
pub fn try_reset(&mut self) -> Result<(), super::AtariEnvError> {
self.inner.try_reset()?;
let raw = self.inner.get_observation();
let frame = self.preprocess_frame(&raw);
self.fill_stack(&frame);
self.prev_raw = raw;
self.last_action = 0;
self.last_reward = 0.0;
self.last_lives = self.inner.lives();
self.terminated = false;
self.truncated = false;
self.rng = SmallRng::seed_from_u64(self.seed);
self.sticky_rolls = 0;
Ok(())
}
pub fn try_step(&mut self, action: i64) -> Result<StepResult, super::AtariEnvError> {
let effective = if self.roll_sticky() {
self.last_action
} else {
action
};
self.last_action = effective;
let mut reward = 0.0f32;
let mut terminated = false;
let mut truncated = false;
let mut lives = self.last_lives;
let mut second_last = std::mem::take(&mut self.prev_raw);
let mut last = second_last.clone();
let skip = self.config.frame_skip.max(1);
for i in 0..skip {
let result = self.inner.try_step(effective)?;
reward += result.reward;
terminated = result.terminated;
truncated = result.truncated;
lives = self.inner.lives();
if i == skip - 1 {
last = result.observation;
} else if i == skip - 2 {
second_last = result.observation;
}
if terminated || truncated {
last = self.inner.get_observation();
second_last = last.clone();
break;
}
}
let pooled = if skip >= 2 {
Self::max_pool(&second_last, &last)
} else {
last.clone()
};
let frame = self.preprocess_frame(&pooled);
self.push_frame(frame);
self.prev_raw = last;
let life_lost = self.config.life_loss_termination && lives < self.last_lives;
self.last_lives = lives;
self.last_reward = reward;
self.terminated = terminated || life_lost;
self.truncated = truncated;
Ok(StepResult {
observation: self.flat_observation(),
reward,
terminated: self.terminated,
truncated: self.truncated,
info: StepInfo::default(),
})
}
pub fn try_clone_state(&self) -> Result<PreprocessState, super::AtariEnvError> {
let inner = self.inner.try_clone_state()?;
let mut stack = Vec::with_capacity(self.config.frame_stack * self.frame_len());
for slot in &self.stack {
stack.extend_from_slice(slot);
}
Ok(PreprocessState {
inner,
stack,
prev_raw: self.prev_raw.clone(),
last_action: self.last_action,
last_lives: self.last_lives,
terminated: self.terminated,
sticky_rolls: self.sticky_rolls,
})
}
pub fn try_restore_state(
&mut self,
state: &PreprocessState,
) -> Result<(), super::AtariEnvError> {
self.inner.try_restore_state(&state.inner)?;
let frame_len = self.frame_len();
self.stack = state.stack.chunks(frame_len).map(<[f32]>::to_vec).collect();
self.prev_raw = state.prev_raw.clone();
self.last_action = state.last_action;
self.last_lives = state.last_lives;
self.terminated = state.terminated;
self.truncated = false;
self.rng = SmallRng::seed_from_u64(self.seed);
for _ in 0..state.sticky_rolls {
let _: f32 = self.rng.random();
}
self.sticky_rolls = state.sticky_rolls;
Ok(())
}
}
impl std::fmt::Debug for AtariPreprocess {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AtariPreprocess")
.field("inner", &self.inner)
.field("config", &self.config)
.field("seed", &self.seed)
.field("sticky_rolls", &self.sticky_rolls)
.finish()
}
}
impl Environment for AtariPreprocess {
type Action = i64;
type State = PreprocessState;
fn reset(&mut self) {
self.try_reset()
.unwrap_or_else(|e| panic!("AtariPreprocess::reset failed: {e}"));
}
fn get_observation(&self) -> Vec<f32> {
self.flat_observation()
}
fn step(&mut self, action: i64) -> StepResult {
self.try_step(action)
.unwrap_or_else(|e| panic!("AtariPreprocess::step failed: {e}"))
}
fn observation_space(&self) -> SpaceInfo {
SpaceInfo {
shape: vec![self.config.frame_stack, self.config.screen_size, self.config.screen_size],
space_type: SpaceType::Box,
}
}
fn action_space(&self) -> SpaceInfo {
self.inner.action_space()
}
fn render(&self) -> Vec<u8> {
self.stack
.last()
.map(|slot| slot.iter().map(|&p| p.clamp(0.0, 255.0) as u8).collect())
.unwrap_or_default()
}
fn close(&mut self) {
self.inner.close();
}
fn clone_state(&self) -> PreprocessState {
self.try_clone_state()
.unwrap_or_else(|e| panic!("AtariPreprocess::clone_state failed: {e}"))
}
fn restore_state(&mut self, state: &PreprocessState) {
self.try_restore_state(state)
.unwrap_or_else(|e| panic!("AtariPreprocess::restore_state failed: {e}"));
}
}
#[cfg(test)]
mod tests {
use std::{
io::{BufReader, Read, Write},
net::{TcpListener, TcpStream},
sync::atomic::{AtomicUsize, Ordering},
thread,
};
use super::*;
use crate::env::games::atari::protocol::{self, Command, OBS_LEN, Response};
#[test]
fn grayscale_matches_bt601() {
let mut raw = vec![0.0f32; OBS_LEN];
raw[0] = 100.0; raw[1] = 150.0; raw[2] = 200.0; let gray = AtariPreprocess::grayscale(&raw);
let expected = 0.299 * 100.0 + 0.587 * 150.0 + 0.114 * 200.0;
assert!((gray[0] - expected).abs() < 1e-3, "got {}, want {expected}", gray[0]);
}
#[test]
fn downsample_produces_target_dims() {
let src: Vec<f32> = (0..OBS_HEIGHT * OBS_WIDTH).map(|i| (i % 256) as f32).collect();
let out = AtariPreprocess::downsample(&src, OBS_HEIGHT, OBS_WIDTH, 84);
assert_eq!(out.len(), 84 * 84);
assert!(out.iter().all(|&v| (0.0..=255.0).contains(&v)));
}
#[test]
fn max_pool_picks_per_pixel_maximum() {
let a = vec![1.0, 5.0, 3.0, 2.0];
let b = vec![4.0, 2.0, 3.0, 9.0];
let pooled = AtariPreprocess::max_pool(&a, &b);
assert_eq!(pooled, vec![4.0, 5.0, 3.0, 9.0]);
}
fn flat_obs(value: f32, reward: f32, terminated: bool, lives: u32) -> Response {
Response::Obs { terminated, truncated: false, reward, lives, pixels: vec![value; OBS_LEN] }
}
fn mock_preprocess<F>(seed: u64, config: PreprocessConfig, serve: F) -> AtariPreprocess
where
F: FnOnce(TcpStream) + Send + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let addr = listener.local_addr().expect("local addr");
thread::spawn(move || {
if let Ok((stream, _)) = listener.accept() {
serve(stream);
}
});
let stream = TcpStream::connect(addr).expect("connect to mock worker");
let reader: Box<dyn Read + Send> = Box::new(stream.try_clone().expect("clone stream"));
let writer: Box<dyn Write + Send> = Box::new(stream);
let inner = AtariEnv::from_transport(reader, writer, "pong", seed);
AtariPreprocess::wrap(inner, seed, config)
}
fn counting_worker(stream: TcpStream) {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut writer = stream;
let mut counter = 0.0f32;
loop {
match protocol::read_command(&mut reader) {
Ok(Command::Reset(_)) => {
counter = 0.0;
protocol::write_response(&mut writer, &flat_obs(10.0, 0.0, false, 3)).unwrap();
}
Ok(Command::Step(_)) => {
counter += 1.0;
protocol::write_response(&mut writer, &flat_obs(counter, 1.0, false, 3))
.unwrap();
}
Ok(Command::CloneState) => {
protocol::write_response(&mut writer, &Response::State(vec![0x01])).unwrap();
}
Ok(Command::RestoreState(_)) => {
protocol::write_response(&mut writer, &flat_obs(99.0, 0.0, false, 3)).unwrap();
}
Ok(Command::Close) | Err(_) => break,
}
}
}
#[test]
fn reset_fills_stack_with_first_frame() {
let cfg =
PreprocessConfig { frame_skip: 4, frame_stack: 4, sticky_p: 0.0, ..Default::default() };
let mut env = mock_preprocess(1, cfg, counting_worker);
env.try_reset().unwrap();
let obs = env.get_observation();
assert_eq!(obs.len(), 4 * 84 * 84);
let frame_len = 84 * 84;
let first = &obs[..frame_len];
for k in 1..4 {
let slot = &obs[k * frame_len..(k + 1) * frame_len];
assert_eq!(slot, first, "all reset slots must be identical");
}
assert!((first[0] - 10.0 / 255.0).abs() < 1e-6);
assert!(obs.iter().all(|&v| (0.0..=1.0).contains(&v)), "obs normalized to 0..1");
}
#[test]
fn step_pushes_newest_frame_last_oldest_first() {
let cfg =
PreprocessConfig { frame_skip: 1, frame_stack: 4, sticky_p: 0.0, ..Default::default() };
let mut env = mock_preprocess(1, cfg, counting_worker);
env.try_reset().unwrap();
let result = env.try_step(0).unwrap();
let frame_len = 84 * 84;
let obs = result.observation;
assert!((obs[0] - 10.0 / 255.0).abs() < 1e-6, "oldest slot = reset frame");
let newest = &obs[3 * frame_len..];
assert!((newest[0] - 1.0 / 255.0).abs() < 1e-6, "newest slot = step frame");
}
#[test]
fn observation_space_reports_stack_shape() {
let env = mock_preprocess(1, PreprocessConfig::default(), counting_worker);
assert_eq!(env.observation_space().shape, vec![4, 84, 84]);
matches!(env.observation_space().space_type, SpaceType::Box)
.then_some(())
.expect("box space");
assert_eq!(env.action_space().shape, vec![6]);
}
#[test]
fn frame_skip_max_pools_last_two_frames() {
let cfg =
PreprocessConfig { frame_skip: 4, frame_stack: 4, sticky_p: 0.0, ..Default::default() };
let mut env = mock_preprocess(1, cfg, counting_worker);
env.try_reset().unwrap();
let result = env.try_step(0).unwrap();
assert_eq!(result.reward, 4.0, "reward accumulates over 4 skipped frames");
let frame_len = 84 * 84;
let newest = &result.observation[3 * frame_len..];
assert!((newest[0] - 4.0 / 255.0).abs() < 1e-6, "pooled newest frame = max(3,4)=4");
}
#[test]
fn sticky_action_fraction_near_p() {
let cfg = PreprocessConfig {
frame_skip: 1,
frame_stack: 4,
sticky_p: 0.25,
life_loss_termination: false,
screen_size: 84,
};
fn action_echo_worker(stream: TcpStream) {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut writer = stream;
loop {
match protocol::read_command(&mut reader) {
Ok(Command::Reset(_)) => {
protocol::write_response(&mut writer, &flat_obs(0.0, 0.0, false, 3))
.unwrap();
}
Ok(Command::Step(a)) => {
protocol::write_response(&mut writer, &flat_obs(0.0, a as f32, false, 3))
.unwrap();
}
Ok(Command::Close) | Err(_) => break,
_ => break,
}
}
}
let mut env = mock_preprocess(12345, cfg, action_echo_worker);
env.try_reset().unwrap();
let n = 10_000usize;
let mut sticky_hits = 0usize;
for i in 0..n {
let requested = i as i64;
let result = env.try_step(requested).unwrap();
let effective = result.reward as i64;
if effective != requested {
sticky_hits += 1;
}
}
let frac = sticky_hits as f64 / n as f64;
assert!((frac - 0.25).abs() < 0.02, "sticky fraction {frac} not near 0.25");
}
#[test]
fn life_loss_terminates_episode() {
static STEPS: AtomicUsize = AtomicUsize::new(0);
STEPS.store(0, Ordering::SeqCst);
fn life_worker(stream: TcpStream) {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut writer = stream;
loop {
match protocol::read_command(&mut reader) {
Ok(Command::Reset(_)) => {
protocol::write_response(&mut writer, &flat_obs(0.0, 0.0, false, 2))
.unwrap();
}
Ok(Command::Step(_)) => {
let n = STEPS.fetch_add(1, Ordering::SeqCst);
let lives = if n == 0 { 2 } else { 1 };
protocol::write_response(&mut writer, &flat_obs(0.0, 0.0, false, lives))
.unwrap();
}
Ok(Command::Close) | Err(_) => break,
_ => break,
}
}
}
let cfg = PreprocessConfig {
frame_skip: 1,
frame_stack: 4,
sticky_p: 0.0,
life_loss_termination: true,
screen_size: 84,
};
let mut env = mock_preprocess(1, cfg, life_worker);
env.try_reset().unwrap();
let first = env.try_step(0).unwrap();
assert!(!first.terminated, "no life lost yet");
let second = env.try_step(0).unwrap();
assert!(second.terminated, "life dropped 2 -> 1 must terminate the episode");
}
#[test]
fn life_loss_disabled_does_not_terminate() {
static STEPS2: AtomicUsize = AtomicUsize::new(0);
STEPS2.store(0, Ordering::SeqCst);
fn life_worker2(stream: TcpStream) {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut writer = stream;
loop {
match protocol::read_command(&mut reader) {
Ok(Command::Reset(_)) => {
protocol::write_response(&mut writer, &flat_obs(0.0, 0.0, false, 2))
.unwrap();
}
Ok(Command::Step(_)) => {
let n = STEPS2.fetch_add(1, Ordering::SeqCst);
let lives = if n == 0 { 2 } else { 1 };
protocol::write_response(&mut writer, &flat_obs(0.0, 0.0, false, lives))
.unwrap();
}
Ok(Command::Close) | Err(_) => break,
_ => break,
}
}
}
let cfg = PreprocessConfig {
frame_skip: 1,
frame_stack: 4,
sticky_p: 0.0,
life_loss_termination: false,
screen_size: 84,
};
let mut env = mock_preprocess(1, cfg, life_worker2);
env.try_reset().unwrap();
env.try_step(0).unwrap();
let second = env.try_step(0).unwrap();
assert!(!second.terminated, "life-loss disabled: no termination on life drop");
}
fn restorable_counting_worker(stream: TcpStream) {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut writer = stream;
let mut counter: f32 = 0.0;
loop {
match protocol::read_command(&mut reader) {
Ok(Command::Reset(_)) => {
counter = 0.0;
protocol::write_response(&mut writer, &flat_obs(10.0, 0.0, false, 3)).unwrap();
}
Ok(Command::Step(_)) => {
counter += 1.0;
protocol::write_response(&mut writer, &flat_obs(counter, 1.0, false, 3))
.unwrap();
}
Ok(Command::CloneState) => {
let blob = counter.to_le_bytes().to_vec();
protocol::write_response(&mut writer, &Response::State(blob)).unwrap();
}
Ok(Command::RestoreState(bytes)) => {
counter = f32::from_le_bytes(bytes[..4].try_into().unwrap());
protocol::write_response(&mut writer, &flat_obs(counter, 0.0, false, 3))
.unwrap();
}
Ok(Command::Close) | Err(_) => break,
}
}
}
#[test]
fn clone_restore_round_trips_trajectory() {
let cfg = PreprocessConfig {
frame_skip: 2,
frame_stack: 4,
sticky_p: 0.25,
life_loss_termination: false,
screen_size: 84,
};
let mut env = mock_preprocess(777, cfg, restorable_counting_worker);
env.try_reset().unwrap();
for _ in 0..5 {
env.try_step(1).unwrap();
}
let snapshot = env.try_clone_state().unwrap();
let actions = [1i64, 2, 1];
let baseline: Vec<_> = actions.iter().map(|&a| env.try_step(a).unwrap()).collect();
env.try_restore_state(&snapshot).unwrap();
let replay: Vec<_> = actions.iter().map(|&a| env.try_step(a).unwrap()).collect();
for (i, (x, y)) in baseline.iter().zip(replay.iter()).enumerate() {
assert_eq!(x.observation, y.observation, "obs mismatch after restore at {i}");
assert_eq!(x.reward, y.reward, "reward mismatch after restore at {i}");
assert_eq!(x.terminated, y.terminated, "term mismatch after restore at {i}");
}
}
#[test]
fn same_seed_same_sticky_sequence() {
let mk = || {
let cfg = PreprocessConfig {
frame_skip: 1,
frame_stack: 4,
sticky_p: 0.25,
life_loss_termination: false,
screen_size: 84,
};
let mut env = mock_preprocess(2024, cfg, counting_worker);
env.try_reset().unwrap();
let mut rewards = Vec::new();
for i in 0..50 {
rewards.push(env.try_step(i as i64).unwrap().reward);
}
rewards
};
assert_eq!(mk(), mk(), "same seed => identical sticky-driven trajectory");
}
}