diff --git a/crates/horizon-client/examples/marker_particles_probe.rs b/crates/horizon-client/examples/marker_particles_probe.rs new file mode 100644 index 000000000..edf624e73 --- /dev/null +++ b/crates/horizon-client/examples/marker_particles_probe.rs @@ -0,0 +1,90 @@ +//! Probe for tools/test_marker_particles_parity.py. +use horizon_client::{ + Result, + animate_tick::{AnimateContext, AnimateTables, animate_tick, marker_particle_target}, + assets::Assets, + biome::Biomes, + blocks::BlockFacts, + chunk::Chunk, + collisions, + command::args::GameMode, + items::ItemStack, + particles::{ParticleEngine, ParticleOptions, ParticleType}, + world::World, +}; +use horizon_core::random::RandomSource; +use serde_json::Value; +use std::{ + collections::HashMap, + fs::File, + io::{BufRead, BufReader, BufWriter, Write}, + path::Path, + sync::Arc, +}; + +fn main() -> Result<()> { + let args: Vec = std::env::args().collect(); + let root = Path::new(".local/horizon-client/26.3"); + let assets = Assets::load(root)?; + let facts = BlockFacts::load(root, assets.registry)?; + let biomes = Biomes::load(root)?; + let collision = collisions::load(root, assets.registry)?; + let tables = AnimateTables::load(root, assets.registry, &biomes)?; + let mut world = World::new(facts.air, 0); + let barrier = assets + .registry + .default_state("minecraft:barrier") + .ok_or("barrier")?; + for cz in -2..2 { + for cx in -2..2 { + world + .chunks + .insert((cx, cz), Arc::new(Chunk::empty(cx, cz))); + for y in 33..96 { + for z in 0..16 { + for x in 0..16 { + world.set(cx * 16 + x, y, cz * 16 + z, barrier)?; + } + } + } + } + } + let mut engine = ParticleEngine::new(root, &HashMap::new()); + let mut out = BufWriter::new(File::create(&args[2])?); + for line in BufReader::new(File::open(&args[1])?).lines() { + let case: Value = serde_json::from_str(&line?)?; + let mode = GameMode::ALL + .into_iter() + .find(|mode| Some(mode.name()) == case["mode"].as_str()) + .ok_or("mode")?; + let stack = ItemStack::new( + &format!("minecraft:{}", case["main"].as_str().ok_or("main")?), + case["count"].as_u64().ok_or("count")? as u32, + ); + let target = marker_particle_target(mode, Some(&stack)); + writeln!(out, "{}", target.unwrap_or("none"))?; + // Exercise the real ambient sampling loop in a dry barrier-filled world. + let options = + ParticleOptions::block(ParticleType::BlockMarker, "item/barrier".into(), 0xFFFFFF); + let options = |_| Some(options.clone()); + let ctx = AnimateContext { + world: &world, + facts: &facts, + collision: &collision, + biomes: &biomes, + registry: assets.registry, + raining_at: &|_| false, + marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)), + }; + engine.seed_randoms(1057, 0, 0); + engine.requested = Some(Vec::new()); + animate_tick(&tables, &mut engine, &ctx, [0, 64, 0]); + for (options, p, v) in engine.requested.take().ok_or("requests")? { + assert_eq!(options.ty, ParticleType::BlockMarker); + assert_eq!(v, [0.0; 3]); + writeln!(out, "marker {} {} {}", p[0], p[1], p[2])?; + } + writeln!(out, "next {}", engine.random().next_int())?; + } + Ok(()) +} diff --git a/crates/horizon-client/src/animate_tick.rs b/crates/horizon-client/src/animate_tick.rs index 059aaf58b..b391c0cbf 100644 --- a/crates/horizon-client/src/animate_tick.rs +++ b/crates/horizon-client/src/animate_tick.rs @@ -6,6 +6,8 @@ use crate::{ biome::Biomes, blocks::{BlockFacts, Fluid}, + command::args::GameMode, + items::ItemStack, particles::{ParticleEngine, ParticleOptions, ParticleType as T}, world::World, }; @@ -254,6 +256,24 @@ pub struct AnimateContext<'a> { pub registry: &'a BlockRegistry, /// `Level.isRainingAt`. pub raining_at: &'a dyn Fn([i32; 3]) -> bool, + /// Creative main-hand marker block and a resolver for each placed state's sprite. + pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option)>, +} + +/// `ClientLevel.getMarkerParticleTarget`: only the selected main-hand item counts. +pub fn marker_particle_target( + mode: GameMode, + main_hand: Option<&ItemStack>, +) -> Option<&'static str> { + if mode != GameMode::Creative { + return None; + } + let stack = main_hand.filter(|stack| stack.count > 0)?; + match stack.item.as_ref() { + "minecraft:barrier" => Some("minecraft:barrier"), + "minecraft:light" => Some("minecraft:light"), + _ => None, + } } impl AnimateContext<'_> { fn state(&self, p: [i32; 3]) -> BlockStateId { @@ -358,6 +378,12 @@ fn do_animate_tick( try_spawn_drip(tables, engine, ctx, [x, y - 1, z], drip, top_solid); } } + if let Some((block, options)) = ctx.marker + && ctx.registry.state(state).is_some_and(|s| s.name == block) + && let Some(options) = options(state) + { + engine.add(&options, pos.map(|v| f64::from(v) + 0.5), [0.0; 3]); + } if !facts.full_collision { let biome = ctx .world diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 6baab9863..7eb22d18e 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1720,8 +1720,14 @@ impl Game<'_> { Some(stack) } fn particle_sprite(&self, state: BlockStateId) -> Option<(String, u32)> { - let models = self - .pack + Self::block_particle_sprite(&self.pack, self.res.facts(), state) + } + fn block_particle_sprite( + pack: &PackResources, + facts: &BlockFacts, + state: BlockStateId, + ) -> Option<(String, u32)> { + let models = pack .render .model_catalog .collect(state, horizon_core::position::BlockPos::new(0, 0, 0)) @@ -1735,9 +1741,9 @@ impl Game<'_> { let layer = face .map(|face| face.layer) .or_else(|| models.iter().find_map(|(_, model)| model.particle))?; - let name = self.pack.layer_names.get(&layer)?.clone(); + let name = pack.layer_names.get(&layer)?.clone(); let tint = if face.is_some_and(|face| face.quad.tint_index >= 0) { - let n = &self.res.facts().get(state).name; + let n = &facts.get(state).name; if n.contains("leaves") || n.contains("vine") { 0x48B518 } else { diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 1feb1236e..cdc7a68c6 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -3,7 +3,7 @@ use super::{Dim, Game}; use horizon_client::fog::{FogData, FogInputs, FogType, fog_type, update_rain_fog}; use horizon_client::{ - animate_tick::{AnimateContext, animate_tick, campfire_particles}, + animate_tick::{AnimateContext, animate_tick, campfire_particles, marker_particle_target}, biome::Biomes, blocks::{BlockFacts, Fluid}, chunk::{HEIGHT, MIN_Y}, @@ -290,6 +290,15 @@ impl Game<'_> { } else { (0.0, 0.0) }; + let marker = marker_particle_target(self.mode, self.stack_in(self.selected)); + let marker_options = |state| { + let (sprite, tint) = Self::block_particle_sprite(&self.pack, self.res.facts(), state)?; + Some(ParticleOptions::block( + ParticleType::BlockMarker, + sprite, + tint, + )) + }; let world = &self.world; let facts = self.res.facts(); let biomes = self.res.biomes(); @@ -306,6 +315,7 @@ impl Game<'_> { biomes, registry: self.pack.assets.registry, raining_at: &raining_at, + marker: marker.map(|name| (name, &marker_options as &dyn Fn(_) -> _)), }; if let Some(e) = eye { let block = [ diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMarkerParticlesOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMarkerParticlesOracle.java new file mode 100644 index 000000000..efcdaf188 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMarkerParticlesOracle.java @@ -0,0 +1,98 @@ +package dev.horizonlab.parity; + +import com.google.gson.JsonParser; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.multiplayer.MultiPlayerGameMode; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.util.RandomSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.level.material.Fluids; + +/** Invoke shipped marker selection and ambient sampling in a dry barrier world. */ +public final class VanillaMarkerParticlesOracle { + static Field field(Class type, String name) throws Exception { + var field = type.getDeclaredField(name); + field.setAccessible(true); + return field; + } + public static final class Player extends LocalPlayer { + ItemStack main, off; + Player() { super(null, null, null, null, null, null, false, null, null); } + @Override public ItemStack getMainHandItem() { return main; } + @Override public ItemStack getItemInHand(InteractionHand hand) { + return hand == InteractionHand.MAIN_HAND ? main : off; + } + } + public static final class Mode extends MultiPlayerGameMode { + GameType mode; + Mode() { super(null, null); } + @Override public GameType getPlayerMode() { return mode; } + } + public static final class MarkerLevel extends ClientLevel { + java.io.BufferedWriter out; + MarkerLevel() { super(null, null, null, null, 0, 0, null, false, 0L, 0); } + @Override public BlockState getBlockState(BlockPos pos) { return Blocks.BARRIER.defaultBlockState(); } + @Override public FluidState getFluidState(BlockPos pos) { return Fluids.EMPTY.defaultFluidState(); } + @Override public void addParticle(ParticleOptions options, double x, double y, double z, + double dx, double dy, double dz) { + if (dx != 0 || dy != 0 || dz != 0) throw new AssertionError("marker velocity"); + try { + out.write("marker " + x + " " + y + " " + z); + out.newLine(); + } catch (java.io.IOException e) { throw new RuntimeException(e); } + } + } + public static void main(String[] args) throws Exception { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + for (var item : BuiltInRegistries.ITEM) { + item.builtInRegistryHolder().bindComponents(net.minecraft.core.component.DataComponentMap.EMPTY); + } + var allocator = Class.forName("sun.misc.Unsafe"); + var unsafe = field(allocator, "theUnsafe").get(null); + var allocate = allocator.getMethod("allocateInstance", Class.class); + var mc = (Minecraft) allocate.invoke(unsafe, Minecraft.class); + var level = (MarkerLevel) allocate.invoke(unsafe, MarkerLevel.class); + var player = (Player) allocate.invoke(unsafe, Player.class); + var mode = (Mode) allocate.invoke(unsafe, Mode.class); + field(ClientLevel.class, "minecraft").set(level, mc); + mc.player = player; + mc.gameMode = mode; + var select = ClientLevel.class.getDeclaredMethod("getMarkerParticleTarget"); + select.setAccessible(true); + try (var out = Files.newBufferedWriter(Path.of(args[1]))) { + level.out = out; + for (var line : Files.readAllLines(Path.of(args[0]))) { + var c = JsonParser.parseString(line).getAsJsonObject(); + mode.mode = GameType.byName(c.get("mode").getAsString()); + player.main = new ItemStack(BuiltInRegistries.ITEM.getValue( + Identifier.parse("minecraft:" + c.get("main").getAsString())), c.get("count").getAsInt()); + player.off = new ItemStack(BuiltInRegistries.ITEM.getValue( + Identifier.parse("minecraft:" + c.get("off").getAsString()))); + var block = (Block) select.invoke(level); + out.write(block == null ? "none" : BuiltInRegistries.BLOCK.getKey(block).toString()); + out.newLine(); + var random = RandomSource.create(1057); + field(Level.class, "random").set(level, random); + level.animateTick(0, 64, 0); + out.write("next " + random.nextInt()); + out.newLine(); + } + } + } +} diff --git a/tools/test_marker_particles_parity.py b/tools/test_marker_particles_parity.py new file mode 100644 index 000000000..54a332ea4 --- /dev/null +++ b/tools/test_marker_particles_parity.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Compare creative marker selection and ambient sampling with the pinned ClientLevel.""" +import itertools +import json + +try: + from . import lab, oracle_harness as harness +except ImportError: + import lab + import oracle_harness as harness + + +def make_input(build): + items = ['air', 'barrier', 'light', 'stone', 'structure_void', 'apple'] + cases = [dict(mode=mode, main=main, off=off, count=count) + for mode, main, off, count in itertools.product( + ['survival', 'creative', 'adventure', 'spectator'], items, items, [0, 1, 64])] + path = build / 'cases.jsonl' + path.write_text(''.join(json.dumps(case) + '\n' for case in cases)) + return path, len(cases) + + +def main(): + harness.differential( + 'marker-particles-parity', 'VanillaMarkerParticlesOracle', 'marker_particles_probe', + make_input, 'Creative main-hand marker selection and barrier marker positions, velocities and random draws', + sources=[lab.ROOT / 'tools/test_marker_particles_parity.py', + lab.ROOT / 'crates/horizon-client/src/animate_tick.rs'], expected_lines=16872) + + +if __name__ == '__main__': + main()