From e84f577a5ae20144555ef740ff587602b623c2fd Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:01:08 +0000 Subject: [PATCH 1/2] add a deterministic harness step that holds flicker, particles, animations, clouds and bobbing still --- pomme-client/src/app/phases/in_game.rs | 41 +++++++++---- pomme-client/src/harness/exec.rs | 4 ++ pomme-client/src/harness/protocol.rs | 4 ++ pomme-client/src/harness/script.rs | 9 ++- pomme-client/src/particle.rs | 5 ++ pomme-client/src/renderer/chunk/atlas.rs | 10 ++++ pomme-client/src/renderer/mod.rs | 4 ++ tests/ingame/README.md | 5 ++ tests/ingame/world/deterministic.toml | 73 ++++++++++++++++++++++++ tests/ingame/world/lightmap.toml | 3 + 10 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 tests/ingame/world/deterministic.toml 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..3c64cd9ac 100644 --- a/pomme-client/src/harness/exec.rs +++ b/pomme-client/src/harness/exec.rs @@ -78,6 +78,10 @@ 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; + 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..cc2849ebd 100644 --- a/pomme-client/src/harness/script.rs +++ b/pomme-client/src/harness/script.rs @@ -44,6 +44,7 @@ struct StepFile { press: Option, bind: Option, option: Option, + deterministic: Option, hover: Option, cursor: Option<[f32; 2]>, scroll: Option, @@ -273,6 +274,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))); } @@ -725,10 +729,13 @@ mod tests { press = { key = "key.keyboard.j" } [[step]] option = { main_hand = "left" } + [[step]] + deterministic = true "#, ) .unwrap(); - assert_eq!(steps.len(), 20); + assert_eq!(steps.len(), 21); + assert_eq!(steps[20], Step::Run(Command::Deterministic)); 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..314e4554a 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 diff --git a/tests/ingame/world/deterministic.toml b/tests/ingame/world/deterministic.toml new file mode 100644 index 000000000..28e6ce988 --- /dev/null +++ b/tests/ingame/world/deterministic.toml @@ -0,0 +1,73 @@ +# The deterministic step holds a scene's frame-to-frame noise still: a torch's +# flicker and smoke, lava's pops, animated textures, server particles and the +# clouds all stay put, so two shots a second apart match. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +chat = "/gamemode creative" + +# Sunk into the grass layer, so it can't flow. +[[step]] +chat = "/fill 79 -61 84 81 -61 85 minecraft:lava" + +[[step]] +chat = "/setblock 82 -60 84 minecraft:torch" + +[[step]] +chat = "/setblock 78 -60 84 minecraft:magma_block" + +[[step]] +chat = "/tp @s 80.5 -60 81.5 0 30" + +[[step]] +expect = { player = { position = [80.5, -60.0, 81.5] } } + +[[step]] +press = { action = "key.toggleGui" } + +[[step]] +deterministic = true + +[[step]] +chat = "/particle minecraft:flame 80 -59.5 84 0.5 0.3 0.5 0 50 force" + +[[step]] +wait_ms = 3000 + +[[step]] +screenshot = "first" + +[[step]] +chat = "/particle minecraft:flame 80 -59.5 84 0.5 0.3 0.5 0 50 force" + +[[step]] +wait_ms = 1000 + +[[step]] +screenshot = "second" + +[[step]] +chat = "/tp @s 80.5 -60 81.5 0 -60" + +[[step]] +expect = { player = { y_rot = 0.0 } } + +[[step]] +wait_ms = 1000 + +[[step]] +screenshot = "sky" + +[[step]] +chat = "/fill 79 -61 84 81 -61 85 minecraft:grass_block" + +[[step]] +chat = "/fill 78 -60 84 82 -60 84 minecraft:air" diff --git a/tests/ingame/world/lightmap.toml b/tests/ingame/world/lightmap.toml index d4da9a5a1..32bbddb5f 100644 --- a/tests/ingame/world/lightmap.toml +++ b/tests/ingame/world/lightmap.toml @@ -24,6 +24,9 @@ expect = { player = { position = [60.5, -60.0, 50.5] } } [[step]] press = { action = "key.toggleGui" } +[[step]] +deterministic = true + [[step]] wait_ms = 3000 From 85036c44fdca769de4ccd026d716bbe33146729c Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:24:10 +0000 Subject: [PATCH 2/2] zero flicker when the deterministic step runs and add a match_screenshots check --- pomme-client/src/harness/exec.rs | 1 + pomme-client/src/harness/script.rs | 41 +++++++++++++++++++++++++-- tests/ingame/README.md | 4 +++ tests/ingame/world/deterministic.toml | 3 ++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/pomme-client/src/harness/exec.rs b/pomme-client/src/harness/exec.rs index 3c64cd9ac..3ae89c3f9 100644 --- a/pomme-client/src/harness/exec.rs +++ b/pomme-client/src/harness/exec.rs @@ -80,6 +80,7 @@ pub fn run(command: &Command, core: &mut AppCore, phase: &mut AppPhase) -> Outco 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), diff --git a/pomme-client/src/harness/script.rs b/pomme-client/src/harness/script.rs index cc2849ebd..e4c4b20cd 100644 --- a/pomme-client/src/harness/script.rs +++ b/pomme-client/src/harness/script.rs @@ -51,6 +51,7 @@ struct StepFile { text: Option, wait_ms: Option, screenshot: Option, + match_screenshots: Option<[String; 2]>, expect: Option, timeout_ms: Option, } @@ -234,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), } @@ -295,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))); } @@ -389,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 @@ -397,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)), @@ -420,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, @@ -731,11 +762,17 @@ mod tests { option = { main_hand = "left" } [[step]] deterministic = true + [[step]] + match_screenshots = ["first", "second"] "#, ) .unwrap(); - assert_eq!(steps.len(), 21); + 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/tests/ingame/README.md b/tests/ingame/README.md index 314e4554a..3e03e3792 100644 --- a/tests/ingame/README.md +++ b/tests/ingame/README.md @@ -154,6 +154,10 @@ wait_ms = 1000 [[step]] screenshot = "equipped" # target/ingame/