diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 15de3a853..6c8cedfc8 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -276,6 +276,8 @@ pub struct GameState { pub light_environment: crate::world::environment::LightEnvironment, /// Vanilla `LightmapRenderStateExtractor.blockLightFlicker`. pub block_light_flicker: f32, + /// Set by the test harness's `deterministic` step. + pub deterministic: bool, /// F3+F4 game-mode switcher overlay, while open. pub game_mode_switcher: Option, /// Spectator hotbar menu (vanilla `SpectatorGui`). Not a GUI screen: the @@ -543,6 +545,7 @@ impl GameState { default_clock: None, light_environment: Default::default(), block_light_flicker: 0.0, + deterministic: false, game_mode_switcher: None, spectator: Default::default(), switcher_was_open: false, @@ -2324,11 +2327,17 @@ pub fn update_game( // `/tick freeze` (`isLevelRunningNormally`); pomme tracks neither yet. let ticked = core.tick_accumulator >= TICK_RATE; if ticked { - gfx.renderer.tick_texture_animations(); + if game.deterministic { + gfx.renderer.hold_texture_animations(); + } else { + gfx.renderer.tick_texture_animations(); + } } while core.tick_accumulator >= TICK_RATE { game.tick_count = game.tick_count.wrapping_add(1); - game.block_light_flicker = tick_block_light_flicker(game.block_light_flicker); + if !game.deterministic { + game.block_light_flicker = tick_block_light_flicker(game.block_light_flicker); + } // Vanilla `Minecraft.tick` order: `gameMode.tick` drives the connection // tick (and so the level load tracker) before the level's entities, // i.e. before the local player moves or sends anything. @@ -2361,11 +2370,13 @@ pub fn update_game( } game.item_entity_store .tick(&game.chunk_store, game.block_registry.block_state_table()); - game.level_runtime.animate_tick( - &game.block_registry, - &game.chunk_store, - *game.player.position, - ); + if !game.deterministic { + game.level_runtime.animate_tick( + &game.block_registry, + &game.chunk_store, + *game.player.position, + ); + } game.particle_store .tick(&game.chunk_store, game.block_registry.block_state_table()); game.block_entity_anim.tick(); @@ -2604,8 +2615,11 @@ pub fn update_game( .prev_walk_dist .lerp(game.player.walk_dist, partial_tick); let bob_amount = game.player.prev_bob.lerp(game.player.bob, partial_tick); - gfx.renderer - .set_view_bob(bob_walk, bob_amount, core.menu.view_bobbing); + gfx.renderer.set_view_bob( + bob_walk, + bob_amount, + core.menu.view_bobbing && !game.deterministic, + ); gfx.renderer.set_post_effect(game.drawn_spectator_effect()); gfx.renderer.update_third_person_distance( game.block_registry.render_view(), @@ -4170,7 +4184,12 @@ pub fn update_game( ) }; - let particle_quads = if benchmark_running { + if game.deterministic { + game.particle_store.clear(); + } + // Benchmarks and deterministic tests draw neither particles nor clouds. + let no_ambience = benchmark_running || game.deterministic; + let particle_quads = if no_ambience { Vec::new() } else { game.particle_store @@ -4245,7 +4264,7 @@ pub fn update_game( &block_entity_renders, &particle_quads, &weather_columns, - if benchmark_running { + if no_ambience { crate::renderer::CloudMode::Off } else { core.menu.cloud_mode diff --git a/pomme-client/src/harness/exec.rs b/pomme-client/src/harness/exec.rs index e84f29d2c..3ae89c3f9 100644 --- a/pomme-client/src/harness/exec.rs +++ b/pomme-client/src/harness/exec.rs @@ -78,6 +78,11 @@ pub fn run(command: &Command, core: &mut AppCore, phase: &mut AppPhase) -> Outco Command::Press(press) => press_key(core, press), Command::Bind(bind) => bind_keys(core, bind), Command::Option(options) => set_options(core, options), + Command::Deterministic => { + game.deterministic = true; + game.block_light_flicker = 0.0; + Ok(Value::Null) + } Command::Hover(target) => hover(core, gfx, game, target), Command::Cursor { at } => { let gs = gui_scale(core, gfx); diff --git a/pomme-client/src/harness/protocol.rs b/pomme-client/src/harness/protocol.rs index 1fa942b01..61c86b1ae 100644 --- a/pomme-client/src/harness/protocol.rs +++ b/pomme-client/src/harness/protocol.rs @@ -75,6 +75,10 @@ pub enum Command { Screenshot { path: PathBuf, }, + /// Holds what varies frame to frame still for the rest of the session: + /// block light flicker, particles, animated textures, clouds and view + /// bobbing, so two runs of a script draw the same frames. + Deterministic, } #[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] diff --git a/pomme-client/src/harness/script.rs b/pomme-client/src/harness/script.rs index 8fa0e86fe..e4c4b20cd 100644 --- a/pomme-client/src/harness/script.rs +++ b/pomme-client/src/harness/script.rs @@ -44,12 +44,14 @@ struct StepFile { press: Option, bind: Option, option: Option, + deterministic: Option, hover: Option, cursor: Option<[f32; 2]>, scroll: Option, text: Option, wait_ms: Option, screenshot: Option, + match_screenshots: Option<[String; 2]>, expect: Option, timeout_ms: Option, } @@ -233,6 +235,8 @@ enum Step { Run(Command), Wait(Duration), Screenshot(String), + /// Two of the script's screenshots, which must be pixel for pixel equal. + MatchScreenshots([String; 2]), Expect(Expect, Duration), } @@ -273,6 +277,9 @@ impl StepFile { if let Some(options) = self.option { steps.push(Step::Run(Command::Option(options))); } + if self.deterministic == Some(true) { + steps.push(Step::Run(Command::Deterministic)); + } if let Some(hover) = self.hover { steps.push(Step::Run(Command::Hover(hover))); } @@ -291,6 +298,9 @@ impl StepFile { if let Some(name) = self.screenshot { steps.push(Step::Screenshot(name)); } + if let Some(names) = self.match_screenshots { + steps.push(Step::MatchScreenshots(names)); + } if let Some(expect) = self.expect { steps.push(Step::Expect(expect, timeout.unwrap_or(DEFAULT_TIMEOUT))); } @@ -385,7 +395,7 @@ impl Script { let path = match &self.pending_shot { Some(path) => path.clone(), None => { - let path = self.out_dir.join(format!("{name}.png")); + let path = self.shot_path(name); exec::run(&Command::Screenshot { path: path.clone() }, core, phase); self.pending_shot = Some(path.clone()); path @@ -393,6 +403,7 @@ impl Script { }; exec::screenshot_progress(phase, &path, started) } + Step::MatchScreenshots(names) => Some(self.match_screenshots(names)), Step::Expect(expect, timeout) => match check(expect, phase) { Ok(()) => Some(Ok(())), Err(e) if started.elapsed() > *timeout => Some(Err(e)), @@ -416,6 +427,30 @@ impl Script { } } + fn shot_path(&self, name: &str) -> PathBuf { + self.out_dir.join(format!("{name}.png")) + } + + fn match_screenshots(&self, [a, b]: &[String; 2]) -> Result<(), String> { + let load = |name: &String| { + image::open(self.shot_path(name)) + .map(|image| image.into_rgba8()) + .map_err(|e| format!("{name}: {e}")) + }; + let (first, second) = (load(a)?, load(b)?); + if first.dimensions() != second.dimensions() { + return Err(format!("{a} and {b} differ in size")); + } + let differing = first + .pixels() + .zip(second.pixels()) + .filter(|(p, q)| p != q) + .count(); + ensure(differing == 0, || { + format!("{a} and {b} differ in {differing} pixels") + }) + } + fn fail(&mut self, error: String) -> i32 { self.reports.push(StepReport { step: self.next + 1, @@ -725,10 +760,19 @@ mod tests { press = { key = "key.keyboard.j" } [[step]] option = { main_hand = "left" } + [[step]] + deterministic = true + [[step]] + match_screenshots = ["first", "second"] "#, ) .unwrap(); - assert_eq!(steps.len(), 20); + assert_eq!(steps.len(), 22); + assert_eq!(steps[20], Step::Run(Command::Deterministic)); + assert_eq!( + steps[21], + Step::MatchScreenshots(["first".into(), "second".into()]) + ); assert_eq!( steps[19], Step::Run(Command::Option(OptionSet { diff --git a/pomme-client/src/particle.rs b/pomme-client/src/particle.rs index 0683e0be0..7536a4ec3 100644 --- a/pomme-client/src/particle.rs +++ b/pomme-client/src/particle.rs @@ -559,6 +559,11 @@ impl ParticleStore { self.pending.push(particle); } + pub fn clear(&mut self) { + self.particles.clear(); + self.pending.clear(); + } + pub fn tick(&mut self, chunks: &ChunkStore, block_state_table: BlockStateTable) { let frames = self.end_rod_frames; self.particles diff --git a/pomme-client/src/renderer/chunk/atlas.rs b/pomme-client/src/renderer/chunk/atlas.rs index 3ffff75ab..583f6209f 100644 --- a/pomme-client/src/renderer/chunk/atlas.rs +++ b/pomme-client/src/renderer/chunk/atlas.rs @@ -786,6 +786,16 @@ impl TextureAtlas { self.animation_ticked = true; } + /// Holds every animated sprite on its first frame. + pub fn hold_animations(&mut self) { + for sprite in &mut self.animated { + sprite.dirty |= sprite.frame != 0; + sprite.frame = 0; + sprite.sub_frame = 0; + } + self.animation_ticked = true; + } + /// Vanilla `uploadAnimationFrames`: writes every sprite that needs it /// into its rectangle at each of its levels. pub fn record_animation_upload( diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index f02782d65..c20df618e 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -826,6 +826,10 @@ impl Renderer { self.atlas.tick_animations(); } + pub fn hold_texture_animations(&mut self) { + self.atlas.hold_animations(); + } + /// Redraws the lightmap with `info` on the next frame, as vanilla does /// once per tick. pub fn tick_lightmap(&mut self, info: LightmapInfo) { diff --git a/tests/ingame/README.md b/tests/ingame/README.md index 260f8b71b..3e03e3792 100644 --- a/tests/ingame/README.md +++ b/tests/ingame/README.md @@ -142,6 +142,11 @@ bind = { action = "key.drop", keys = ["key.keyboard.j"] } # this session only; [[step]] option = { main_hand = "left" } # sets client options, as the # options screen does +[[step]] +deterministic = true # holds flicker, particles, + # animated textures, clouds and + # view bobbing still for the rest + # of the script [[step]] wait_ms = 1000 @@ -149,6 +154,10 @@ wait_ms = 1000 [[step]] screenshot = "equipped" # target/ingame/