Merge worktree-agent-abc3c79c07e209fba

This commit is contained in:
Ludovic Marie committed 2026-10-10 07:33:10 +02:00
commit 40802dd9af
9 files changed
+1022 -25

No files matched your search

+10 -1
View File
@@ -509,7 +509,16 @@ impl Items {
return vec![];
};
let name = block.name.trim_start_matches("minecraft:");
let Some(table) = self.loot.get(&format!("minecraft:blocks/{name}")) else {
// `Blocks.wallVariant`: wall signs, banners, heads, fans and torches
// reuse the standing block's loot table.
let standing = name.replacen("_wall_", "_", 1);
let standing = standing.strip_prefix("wall_").unwrap_or(&standing);
let standing = standing.replacen("wall_", "", 1);
let table = self
.loot
.get(&format!("minecraft:blocks/{name}"))
.or_else(|| self.loot.get(&format!("minecraft:blocks/{standing}")));
let Some(table) = table else {
return vec![];
};
let ctx = LootContext {
@@ -0,0 +1,321 @@
//! Rust side of the block update, random tick and redstone parity harnesses
//! (`tools/test_block_update_parity.py`, `test_random_tick_parity.py`,
//! `test_redstone_parity.py`): replays `VanillaBlockUpdateOracle` cases against
//! `Behaviours` over a map-backed level and prints the same result text.
use horizon_core::{
geometry::{Aabb, Direction},
position::{BlockPos, ChunkPos},
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
level::{
Behaviours, Dimension, EntityQuery, GameRules, LevelAccess, LevelEvent, Ticks,
data::load_registry,
},
registry::BlockStateId,
};
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
fmt::Write as _,
io::{BufRead, Write},
path::Path,
};
struct Cells {
cells: HashMap<BlockPos, BlockStateId>,
air: BlockStateId,
random: LegacyRandom,
time: i64,
sky: i32,
block: i32,
raining: bool,
thundering: bool,
events: Vec<LevelEvent>,
}
impl LevelAccess for Cells {
fn block_state(&self, pos: BlockPos) -> BlockStateId {
self.cells.get(&pos).copied().unwrap_or(self.air)
}
fn set_raw(&mut self, pos: BlockPos, state: BlockStateId) -> Option<BlockStateId> {
let old = self.block_state(pos);
if state == self.air {
self.cells.remove(&pos);
} else {
self.cells.insert(pos, state);
}
Some(old)
}
fn is_loaded(&self, _pos: BlockPos) -> bool {
true
}
fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
}
fn game_time(&self) -> i64 {
self.time
}
fn block_light(&self, _pos: BlockPos) -> i32 {
self.block
}
fn sky_light(&self, _pos: BlockPos) -> i32 {
self.sky
}
fn is_raining(&self) -> bool {
self.raining
}
fn is_thundering(&self) -> bool {
self.thundering
}
fn is_raining_at(&self, _pos: BlockPos) -> bool {
self.raining
}
fn dimension(&self) -> Dimension {
Dimension::Overworld
}
fn game_rules(&self) -> GameRules {
GameRules::default()
}
fn count_entities(&self, _area: Aabb, _query: EntityQuery) -> usize {
0
}
fn emit(&mut self, event: LevelEvent) {
self.events.push(event);
}
}
fn pos_of(value: &Value) -> BlockPos {
let c: Vec<i32> = value
.as_array()
.unwrap()
.iter()
.map(|v| v.as_i64().unwrap() as i32)
.collect();
BlockPos::new(c[0], c[1], c[2])
}
fn p(pos: BlockPos) -> String {
format!("{},{},{}", pos.x, pos.y, pos.z)
}
fn summary(level: &Cells, before: &HashMap<BlockPos, BlockStateId>, out: &mut String) {
let mut all: BTreeMap<(i32, i32, i32), ()> = BTreeMap::new();
for pos in before.keys().chain(level.cells.keys()) {
all.insert((pos.x, pos.y, pos.z), ());
}
for (x, y, z) in all.keys() {
let pos = BlockPos::new(*x, *y, *z);
let a = before.get(&pos).copied().unwrap_or(level.air);
let b = level.block_state(pos);
if a != b {
let _ = writeln!(out, "set {} {}", p(pos), b.0);
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let resources = args.next().expect("resource directory");
let cases = args.next().expect("case file");
let output = args.next().expect("output file");
let resources = Path::new(&resources);
let registry = load_registry(resources)?;
let behaviours = Behaviours::load(&registry, resources)?;
let air = registry.default_state("minecraft:air").unwrap();
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
for (index, line) in std::io::BufReader::new(std::fs::File::open(cases)?)
.lines()
.enumerate()
{
let case: Value = serde_json::from_str(&line?)?;
let mut cells = HashMap::new();
for cell in case["cells"].as_array().unwrap() {
let c = cell.as_array().unwrap();
let state = BlockStateId(c[3].as_i64().unwrap() as u32);
if state != air {
cells.insert(
BlockPos::new(
c[0].as_i64().unwrap() as i32,
c[1].as_i64().unwrap() as i32,
c[2].as_i64().unwrap() as i32,
),
state,
);
}
}
let before = cells.clone();
let light = case.get("light").and_then(|v| v.as_array());
let mut level = Cells {
cells,
air,
random: LegacyRandom::new(case["seed"].as_i64().unwrap()),
time: case.get("time").and_then(|v| v.as_i64()).unwrap_or(0),
sky: light.map_or(15, |l| l[0].as_i64().unwrap() as i32),
block: light.map_or(0, |l| l[1].as_i64().unwrap() as i32),
raining: case
.get("raining")
.and_then(|v| v.as_bool())
.unwrap_or(false),
thundering: case
.get("thundering")
.and_then(|v| v.as_bool())
.unwrap_or(false),
events: Vec::new(),
};
let mut ticks = Ticks::new(0);
for x in -3..=3 {
for z in -3..=3 {
ticks.add_empty_chunk(ChunkPos::new(x, z))?;
}
}
let mut result = format!("case {index}\n");
let origin = pos_of(&case["pos"]);
match case["mode"].as_str().unwrap() {
"shape" => {
let dir = Direction::ALL[case["dir"].as_u64().unwrap() as usize];
let neighbour = BlockStateId(case["neighbor"].as_i64().unwrap() as u32);
let state = level.block_state(origin);
let s = behaviours.update_shape(
&mut level,
&mut ticks,
state,
origin,
dir,
{
let [x, y, z] = dir.normal();
origin.offset(x, y, z)
},
neighbour,
);
let _ = writeln!(result, "shape {}", s.0);
}
"random" => behaviours.random_tick(&mut level, &mut ticks, origin),
"tick" => behaviours.scheduled_block_tick(&mut level, &mut ticks, origin),
"ftick" => behaviours.scheduled_fluid_tick(&mut level, &mut ticks, origin),
"neighbor" => {
let from = pos_of(&case["from"]);
let changed = behaviours.block(level.block_state(from));
behaviours.handle_neighbor_changed(&mut level, &mut ticks, origin, changed);
}
"run" => {
for op in case["ops"].as_array().unwrap() {
match op["op"].as_str().unwrap() {
"set" => {
behaviours.set_block(
&mut level,
&mut ticks,
pos_of(&op["pos"]),
BlockStateId(op["state"].as_i64().unwrap() as u32),
op["flags"].as_i64().unwrap() as i32,
);
}
"ticks" => {
let snap = op.get("snap").and_then(|v| v.as_bool()).unwrap_or(false);
for _ in 0..op["n"].as_i64().unwrap() {
level.time += 1;
behaviours.tick_scheduled(&mut level, &mut ticks);
if snap {
let _ = writeln!(result, "tick {}", level.time);
summary(&level, &before, &mut result);
}
}
}
other => panic!("unknown op {other}"),
}
}
}
other => panic!("unknown mode {other}"),
}
summary(&level, &before, &mut result);
// Pending scheduled ticks, sorted as the oracle sorts its rows.
let mut rows: Vec<String> = Vec::new();
for x in -3..=3 {
for z in -3..=3 {
let chunk = ChunkPos::new(x, z);
if let Some(c) = ticks.blocks.chunk(chunk) {
for t in c.queued() {
let block = horizon_world::level::BlockId(t.kind.0 as u16);
rows.push(format!(
"bt {} {} {} {}",
p(t.pos),
behaviours.table.info(block).name,
t.trigger_tick - level.time,
t.priority as i32
));
}
}
if let Some(c) = ticks.fluids.chunk(chunk) {
for t in c.queued() {
let name = ["empty", "flowing_water", "water", "flowing_lava", "lava"]
[t.kind.0 as usize];
rows.push(format!(
"ft {} minecraft:{} {} {}",
p(t.pos),
name,
t.trigger_tick - level.time,
t.priority as i32
));
}
}
}
}
rows.sort();
for r in rows {
result.push_str(&r);
result.push('\n');
}
for event in &level.events {
match event {
LevelEvent::LevelEvent { id, pos, data } => {
let _ = writeln!(result, "L {} {} {}", id, p(*pos), data);
}
LevelEvent::Sound { pos, sound, .. } => {
let _ = writeln!(result, "S {} {}", p(*pos), sound);
}
LevelEvent::DropResources { state, .. } => {
let name = behaviours.table.name(*state);
let short = name.strip_prefix("minecraft:").unwrap_or(name);
// `Blocks.wallVariant` reuses the standing block's loot table.
let short = short.replacen("_wall_", "_", 1);
let short = short.strip_prefix("wall_").unwrap_or(&short);
let short = short.replacen("wall_", "", 1);
let _ = writeln!(result, "D minecraft:blocks/{short}");
}
LevelEvent::FallingBlock { .. } => {
let _ = writeln!(result, "E minecraft:falling_block");
}
LevelEvent::PrimeTnt { .. } => {
let _ = writeln!(result, "E minecraft:tnt");
}
LevelEvent::GameEvent { pos, event, .. } => {
let _ = writeln!(result, "G {} {}", p(*pos), event);
}
LevelEvent::BlockEvent { kind, .. } => {
let _ = writeln!(result, "X {kind}");
}
LevelEvent::GrowTree { .. } => {
let _ = writeln!(result, "X grow_tree");
}
LevelEvent::PlaceFeature { feature, .. } => {
let _ = writeln!(result, "X feature {feature}");
}
LevelEvent::Player { .. } => {
let _ = writeln!(result, "X player");
}
}
}
for e in &ticks.block_events {
let _ = writeln!(
result,
"B {} {} {} {}",
p(e.pos),
behaviours.table.info(e.block).name,
e.param_a,
e.param_b
);
}
let _ = writeln!(result, "rng {}", level.random.next_long());
out.write_all(result.as_bytes())?;
}
Ok(())
}
+13
View File
@@ -568,6 +568,19 @@ impl Behaviours {
) {
self.sim(level, ticks).update_neighbors_at(pos, block);
}
/// `BlockState.handleNeighborChanged(level, pos, changedBlock, orientation, movedByPiston)`
/// for the state currently at `pos`.
pub fn handle_neighbor_changed<L: LevelAccess + ?Sized>(
&self,
level: &mut L,
ticks: &mut Ticks,
pos: BlockPos,
changed: BlockId,
) {
let mut sim = self.sim(level, ticks);
let state = sim.state(pos);
sim.neighbor_changed(state, pos, changed, false);
}
/// Entity collision with a block (`entityInside`) for the entity-sensing
/// blocks: pressure plates, tripwires and buttons hit by arrows are
/// re-evaluated through [`LevelAccess::count_entities`].
@@ -111,6 +111,9 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
let data = self.b.data(state);
if data.is(super::classes::Class::PistonBase) {
self.piston_trigger_event(pos, state, a, b)
} else if data.is(super::classes::Class::Note) {
self.note_trigger_event(pos, state);
true
} else {
false
}
+32 -22
View File
@@ -1067,35 +1067,45 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
self.set_block(pos, b.table.set_bool(next, p.powered, signal), 3);
}
}
/// `NoteBlock.playNote` (block event; instrument pitch from `note`).
/// `NoteBlock.playNote`: queues the block event and emits the game event.
pub(crate) fn note_play(&mut self, pos: BlockPos, state: BlockStateId) {
let b = self.b;
let instrument = b.table.text(state, b.props.instrument);
let mob_head = super::data::INSTRUMENTS
.iter()
.position(|n| *n == instrument)
.is_some_and(|i| super::data::instrument_works_above(i as u8));
if mob_head || b.is_air(self.above_state(pos)) {
self.block_event(pos, b.block(state), 0, 0);
self.game_event(pos, "minecraft:note_block_play");
}
}
/// `NoteBlock.triggerEvent`: the note's sound.
pub(crate) fn note_trigger_event(&mut self, pos: BlockPos, state: BlockStateId) {
let b = self.b;
let instrument = b.table.text(state, b.props.instrument).to_owned();
let mob_head = super::data::INSTRUMENTS
.iter()
.position(|n| *n == instrument)
.is_some_and(|i| super::data::instrument_works_above(i as u8));
if mob_head || b.is_air(self.above_state(pos)) {
let note = b.table.int(state, b.props.note);
let pitch = if mob_head {
1.0
} else {
2f64.powf(f64::from(note - 12) / 12.0) as f32
};
let sound = match instrument.as_str() {
"custom_head" => "minecraft:ui.button.click".to_owned(),
"dragon" => "minecraft:block.note_block.imitate.ender_dragon".to_owned(),
name if mob_head => format!("minecraft:block.note_block.imitate.{name}"),
name => format!("minecraft:block.note_block.{name}"),
};
self.emit(LevelEvent::Sound {
pos,
sound: sound.into(),
volume: 3.0,
pitch,
});
self.game_event(pos, "minecraft:note_block_play");
}
let note = b.table.int(state, b.props.note);
let pitch = if mob_head {
1.0
} else {
2f64.powf(f64::from(note - 12) / 12.0) as f32
};
let sound = match instrument.as_str() {
"custom_head" => "minecraft:ui.button.click".to_owned(),
"dragon" => "minecraft:block.note_block.imitate.ender_dragon".to_owned(),
name if mob_head => format!("minecraft:block.note_block.imitate.{name}"),
name => format!("minecraft:block.note_block.{name}"),
};
self.emit(LevelEvent::Sound {
pos,
sound: sound.into(),
volume: 3.0,
pitch,
});
}
pub(crate) fn note_neighbor_changed(&mut self, pos: BlockPos, state: BlockStateId) {
let b = self.b;
+8 -2
View File
@@ -1,6 +1,6 @@
//! `Level.setBlock`, `LevelChunk.setBlockState` and the
//! `CollectingNeighborUpdater` that orders neighbour and shape updates.
use super::{LevelAccess, LevelEvent, Sim, flags, states::BlockId};
use super::{LevelAccess, LevelEvent, Sim, classes::Class, flags, states::BlockId};
use crate::{fluid::FluidState, registry::BlockStateId};
use horizon_core::{
geometry::Direction,
@@ -338,7 +338,13 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
if self.b.is_air(state) {
return false;
}
self.level_event(2001, pos, state.0 as i32);
let is_fire = {
let data = self.b.data(state);
data.is(Class::BaseFire) || data.is(Class::Fire) || data.is(Class::SoulFire)
};
if !is_fire {
self.level_event(2001, pos, state.0 as i32);
}
if drop {
self.drop_resources(state, pos);
}
@@ -0,0 +1,377 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.*;
import java.util.*;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.util.RandomSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.level.gamerules.GameRules;
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.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.levelgen.LegacyRandomSource;
import net.minecraft.world.level.material.Fluid;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.ticks.LevelTicks;
/**
* Drives vanilla block behaviour (updateShape, randomTick, tick, neighbour
* updates, setBlock cascades and multi-tick runs) over a map-backed headless
* ServerLevel. Shared by tools/test_block_update_parity.py,
* test_random_tick_parity.py and test_redstone_parity.py. Block entities do not
* exist here; entities never exist; every side effect is appended to an event
* log compared against the Rust port.
*/
public final class VanillaBlockUpdateOracle {
static Object unsafe;
static Method allocate;
@SuppressWarnings("unchecked")
static <T> T make(Class<T> type) {
try {
return (T) allocate.invoke(unsafe, type);
} catch (Exception e) {
throw new AssertionError(e);
}
}
static void set(Class<?> owner, Object target, String name, Object value) throws Exception {
Field f = owner.getDeclaredField(name);
f.setAccessible(true);
f.set(target, value);
}
static BlockState AIR;
static String pos(BlockPos p) {
return p.getX() + "," + p.getY() + "," + p.getZ();
}
static final class Chunk extends LevelChunk {
World world;
Chunk() {
super(null, null);
}
@Override
public BlockState setBlockState(BlockPos pos, BlockState state, int flags) {
BlockState old = world.cells.getOrDefault(pos.immutable(), AIR);
if (old == state) {
return null;
}
if (state.isAir()) {
world.cells.remove(pos);
} else {
world.cells.put(pos.immutable(), state);
}
Block newBlock = state.getBlock();
boolean blockChanged = !old.is(newBlock);
boolean movedByPiston = (flags & 64) != 0;
if ((blockChanged || newBlock instanceof net.minecraft.world.level.block.BaseRailBlock) && ((flags & 1) != 0 || movedByPiston)) {
old.affectNeighborsAfterRemoval(world, pos, movedByPiston);
}
if (!world.getBlockState(pos).is(newBlock)) {
return null;
}
if ((flags & 512) == 0) {
state.onPlace(world, pos, old, movedByPiston);
}
return old;
}
@Override
public net.minecraft.world.level.chunk.status.ChunkStatus getPersistedStatus() {
return net.minecraft.world.level.chunk.status.ChunkStatus.FULL;
}
@Override
public net.minecraft.server.level.FullChunkStatus getFullStatus() {
return net.minecraft.server.level.FullChunkStatus.ENTITY_TICKING;
}
}
static final class World extends net.minecraft.server.level.ServerLevel {
Map<BlockPos, BlockState> cells = new HashMap<>();
RandomSource rng;
long time;
long subTick;
Chunk chunk;
LevelTicks<Block> blockTicks;
LevelTicks<Fluid> fluidTicks;
StringBuilder log = new StringBuilder();
int skyLight = 15;
int blockLight = 0;
GameRules rules;
Server server;
boolean raining, thundering;
World() {
super(null, null, null, null, null, null, false, 0L, List.of(), false);
}
void emit(String line) {
log.append(line).append('\n');
}
@Override public BlockState getBlockState(BlockPos p) { return cells.getOrDefault(p, AIR); }
@Override public FluidState getFluidState(BlockPos p) { return getBlockState(p).getFluidState(); }
@Override public BlockEntity getBlockEntity(BlockPos p) { return null; }
@Override public LevelChunk getChunkAt(BlockPos p) { return chunk; }
@Override public LevelChunk getChunk(int x, int z) { return chunk; }
@Override public boolean isLoaded(BlockPos p) { return true; }
@Override public boolean hasChunkAt(BlockPos p) { return true; }
@Override public boolean hasChunksAt(int x0, int z0, int x1, int z1) { return true; }
@Override public boolean hasChunksAt(int x0, int y0, int z0, int x1, int y1, int z1) { return true; }
@Override public boolean hasChunk(int x, int z) { return true; }
@Override public boolean isClientSide() { return false; }
@Override public RandomSource getRandom() { return rng; }
@Override public int getMinY() { return -64; }
@Override public int getHeight() { return 384; }
@Override public long getGameTime() { return time; }
@Override public long nextSubTickCount() { return subTick++; }
@Override public LevelTicks<Block> getBlockTicks() { return blockTicks; }
@Override public LevelTicks<Fluid> getFluidTicks() { return fluidTicks; }
@Override public void sendBlockUpdated(BlockPos p, BlockState a, BlockState b, int f) { }
@Override public void setBlocksDirty(BlockPos p, BlockState a, BlockState b) { }
@Override public net.minecraft.core.RegistryAccess registryAccess() { return RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); }
@Override public net.minecraft.world.flag.FeatureFlagSet enabledFeatures() { return FeatureFlags.DEFAULT_FLAGS; }
@Override public GameRules getGameRules() { return rules; }
@Override public net.minecraft.server.MinecraftServer getServer() { return server; }
@Override public net.minecraft.world.level.border.WorldBorder getWorldBorder() { return new net.minecraft.world.level.border.WorldBorder(); }
@Override public int getSeaLevel() { return 63; }
@Override public int getBrightness(net.minecraft.world.level.LightLayer layer, BlockPos p) { return layer == net.minecraft.world.level.LightLayer.SKY ? skyLight : blockLight; }
@Override public int getRawBrightness(BlockPos p, int darkening) { return Math.max(skyLight - darkening, blockLight); }
@Override public int getSkyDarken() { return 0; }
@Override public boolean isRaining() { return raining; }
@Override public boolean isThundering() { return thundering; }
@Override public boolean isRainingAt(BlockPos p) { return raining; }
@Override public boolean isUnobstructed(BlockState state, BlockPos pos, net.minecraft.world.phys.shapes.CollisionContext c) { return true; }
@Override public boolean isUnobstructed(Entity entity) { return true; }
@Override public List<Entity> getEntities(Entity except, AABB box, java.util.function.Predicate<? super Entity> filter) { return List.of(); }
@Override public <T extends Entity> List<T> getEntitiesOfClass(Class<T> type, AABB box, java.util.function.Predicate<? super T> filter) { return List.of(); }
@Override public List<net.minecraft.server.level.ServerPlayer> players() { return List.of(); }
@Override public net.minecraft.world.entity.player.Player getNearestPlayer(double x, double y, double z, double d, boolean b) { return null; }
@Override public boolean addFreshEntity(Entity entity) {
emit("E " + BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType()));
return true;
}
@Override public void levelEvent(Entity source, int type, BlockPos p, int data) { emit("L " + type + " " + pos(p) + " " + data); }
@Override public void playSeededSound(Entity except, double x, double y, double z, net.minecraft.core.Holder<SoundEvent> sound, SoundSource source, float volume, float pitch, long seed) {
emit("S " + pos(BlockPos.containing(x, y, z)) + " " + sound.value().location());
}
@Override public void playSeededSound(Entity except, Entity src, net.minecraft.core.Holder<SoundEvent> sound, SoundSource source, float volume, float pitch, long seed) {
emit("S entity " + sound.value().location());
}
@Override public void gameEvent(net.minecraft.core.Holder<net.minecraft.world.level.gameevent.GameEvent> e, Vec3 p, net.minecraft.world.level.gameevent.GameEvent.Context c) {
emit("G " + pos(BlockPos.containing(p)) + " " + e.getRegisteredName());
}
@Override public <T extends net.minecraft.core.particles.ParticleOptions> int sendParticles(T p, double x, double y, double z, int n, double a, double b, double c, double s) { return 0; }
@Override public void blockEvent(BlockPos p, Block block, int a, int b) { emit("B " + pos(p) + " " + BuiltInRegistries.BLOCK.getKey(block) + " " + a + " " + b); }
@Override public void updatePOIOnBlockStateChange(BlockPos p, BlockState a, BlockState b) { }
@Override public boolean isPositionEntityTicking(BlockPos p) { return true; }
@Override public boolean shouldTickBlocksAt(BlockPos p) { return true; }
@Override public boolean shouldTickBlocksAt(long p) { return true; }
}
static final class Loot extends net.minecraft.server.ReloadableServerRegistries.Holder {
World world;
Loot() {
super(null);
}
@Override
public net.minecraft.world.level.storage.loot.LootTable getLootTable(net.minecraft.resources.ResourceKey<net.minecraft.world.level.storage.loot.LootTable> id) {
world.emit("D " + id.identifier());
return net.minecraft.world.level.storage.loot.LootTable.EMPTY;
}
}
static final class Server extends net.minecraft.server.MinecraftServer {
Loot loot;
Server() {
super(null, null, null, null, null, null, null, null, null, false, null);
}
@Override public net.minecraft.server.ReloadableServerRegistries.Holder reloadableRegistries() { return loot; }
@Override protected boolean initServer() { return false; }
@Override public int getMaxPlayers() { return 0; }
@Override public net.minecraft.server.permissions.LevelBasedPermissionSet operatorUserPermissions() { return null; }
@Override public net.minecraft.server.permissions.PermissionSet getFunctionCompilationPermissions() { return null; }
@Override public boolean shouldRconBroadcast() { return false; }
@Override protected net.minecraft.util.debugchart.SampleLogger getTickTimeLogger() { return null; }
@Override public boolean isTickTimeLoggingEnabled() { return false; }
@Override public net.minecraft.SystemReport fillServerSystemReport(net.minecraft.SystemReport r) { return r; }
@Override public boolean isDedicatedServer() { return true; }
@Override public int getRateLimitPacketsPerSecond() { return 0; }
@Override public int getCommandSpamThresholdSeconds() { return 0; }
@Override public int getChatSpamThresholdSeconds() { return 0; }
@Override public boolean useNativeTransport() { return false; }
@Override public boolean isPublished() { return false; }
@Override public boolean shouldInformAdmins() { return false; }
@Override public boolean isSingleplayerOwner(net.minecraft.server.players.NameAndId id) { return false; }
}
static List<String> summary(World w, Map<BlockPos, BlockState> before) {
List<String> out = new ArrayList<>();
Set<BlockPos> all = new HashSet<>(before.keySet());
all.addAll(w.cells.keySet());
List<BlockPos> sorted = new ArrayList<>(all);
sorted.sort(Comparator.<BlockPos>comparingInt(q -> q.getX()).thenComparingInt(q -> q.getY()).thenComparingInt(q -> q.getZ()));
for (BlockPos p : sorted) {
BlockState a = before.getOrDefault(p, AIR), b = w.cells.getOrDefault(p, AIR);
if (a != b) {
out.add("set " + pos(p) + " " + Block.getId(b));
}
}
return out;
}
@SuppressWarnings("unchecked")
static void pending(World w, StringBuilder out) throws Exception {
for (var pair : List.of(new Object[]{"bt", w.blockTicks}, new Object[]{"ft", w.fluidTicks})) {
Field f = LevelTicks.class.getDeclaredField("allContainers");
f.setAccessible(true);
var map = (it.unimi.dsi.fastutil.longs.Long2ObjectMap<net.minecraft.world.ticks.LevelChunkTicks<Object>>) f.get(pair[1]);
List<String> rows = new ArrayList<>();
for (var container : map.values()) {
container.getAll().forEach(t -> {
String type = t.type() instanceof Block b ? BuiltInRegistries.BLOCK.getKey(b).toString() : BuiltInRegistries.FLUID.getKey((Fluid) t.type()).toString();
rows.add(pair[0] + " " + pos(t.pos()) + " " + type + " " + (t.triggerTick() - w.time) + " " + t.priority().getValue());
});
}
Collections.sort(rows);
for (String r : rows) out.append(r).append('\n');
}
}
public static void main(String[] args) throws Exception {
net.minecraft.SharedConstants.tryDetectVersion();
net.minecraft.server.Bootstrap.bootStrap();
AIR = Blocks.AIR.defaultBlockState();
Path resources = Path.of(args[0]);
VanillaHeightmapOracle.bindTags(JsonParser.parseString(Files.readString(resources.resolve("block-tags.json"))).getAsJsonObject());
VanillaLevelShapeOracle.bindFluidTags(resources.resolve("data/minecraft/tags/fluid"));
var allocator = Class.forName("sun.misc.Unsafe");
var field = allocator.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = field.get(null);
allocate = allocator.getMethod("allocateInstance", Class.class);
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[2]))); var lines = Files.lines(Path.of(args[1]))) {
int index = 0;
for (var line : (Iterable<String>) lines::iterator) {
var f = JsonParser.parseString(line).getAsJsonObject();
StringBuilder result = new StringBuilder("case " + index++ + "\n");
try {
run(f, result);
} catch (Throwable e) {
Throwable c = e;
while (c.getCause() != null) c = c.getCause();
StackTraceElement[] st = c.getStackTrace();
result.append("error ").append(c.getClass().getSimpleName()).append(' ').append(st.length > 0 ? st[0].toString() : "").append('\n');
}
out.print(result);
}
}
}
static BlockPos readPos(JsonArray a) {
return new BlockPos(a.get(0).getAsInt(), a.get(1).getAsInt(), a.get(2).getAsInt());
}
static World build(JsonObject f) throws Exception {
World w = make(World.class);
w.cells = new HashMap<>();
w.log = new StringBuilder();
for (var cell : f.getAsJsonArray("cells")) {
var c = cell.getAsJsonArray();
BlockState s = Block.stateById(c.get(3).getAsInt());
if (!s.isAir()) w.cells.put(new BlockPos(c.get(0).getAsInt(), c.get(1).getAsInt(), c.get(2).getAsInt()), s);
}
w.rng = new LegacyRandomSource(f.get("seed").getAsLong());
w.time = f.has("time") ? f.get("time").getAsLong() : 0;
w.rules = new GameRules(FeatureFlags.DEFAULT_FLAGS);
if (f.has("light")) {
w.skyLight = f.getAsJsonArray("light").get(0).getAsInt();
w.blockLight = f.getAsJsonArray("light").get(1).getAsInt();
}
w.raining = f.has("raining") && f.get("raining").getAsBoolean();
w.thundering = f.has("thundering") && f.get("thundering").getAsBoolean();
set(Level.class, w, "random", w.rng);
set(Level.class, w, "neighborUpdater", new net.minecraft.world.level.redstone.CollectingNeighborUpdater(w, 512));
w.server = make(Server.class);
w.server.loot = make(Loot.class);
w.server.loot.world = w;
w.chunk = make(Chunk.class);
w.chunk.world = w;
w.blockTicks = new LevelTicks<>(l -> true);
w.fluidTicks = new LevelTicks<>(l -> true);
for (int x = -3; x <= 3; x++) {
for (int z = -3; z <= 3; z++) {
w.blockTicks.addContainer(new net.minecraft.world.level.ChunkPos(x, z), new net.minecraft.world.ticks.LevelChunkTicks<>());
w.fluidTicks.addContainer(new net.minecraft.world.level.ChunkPos(x, z), new net.minecraft.world.ticks.LevelChunkTicks<>());
}
}
return w;
}
static void run(JsonObject f, StringBuilder result) throws Exception {
World w = build(f);
Map<BlockPos, BlockState> before = new HashMap<>(w.cells);
String mode = f.get("mode").getAsString();
BlockPos p = readPos(f.getAsJsonArray("pos"));
BlockState state = w.getBlockState(p);
switch (mode) {
case "shape" -> {
Direction d = Direction.from3DDataValue(f.get("dir").getAsInt());
BlockPos np = p.relative(d);
BlockState n = Block.stateById(f.get("neighbor").getAsInt());
BlockState s = state.updateShape(w, w, p, d, np, n, w.rng);
result.append("shape ").append(Block.getId(s)).append('\n');
}
case "random" -> {
if (state.isRandomlyTicking()) state.randomTick(w, p, w.rng);
FluidState fs = state.getFluidState();
if (fs.isRandomlyTicking()) fs.randomTick(w, p, w.rng);
}
case "tick" -> state.tick(w, p, w.rng);
case "ftick" -> state.getFluidState().tick(w, p, state);
case "neighbor" -> {
BlockPos from = readPos(f.getAsJsonArray("from"));
w.getBlockState(from);
state.handleNeighborChanged(w, p, w.getBlockState(from).getBlock(), null, false);
}
case "run" -> {
for (var op : f.getAsJsonArray("ops")) {
var o = op.getAsJsonObject();
switch (o.get("op").getAsString()) {
case "set" -> w.setBlock(readPos(o.getAsJsonArray("pos")), Block.stateById(o.get("state").getAsInt()), o.get("flags").getAsInt());
case "ticks" -> {
for (int i = 0; i < o.get("n").getAsInt(); i++) {
w.time++;
w.blockTicks.tick(w.time, 65536, (bp, b) -> { if (w.getBlockState(bp).is(b)) w.getBlockState(bp).tick(w, bp, w.rng); });
w.fluidTicks.tick(w.time, 65536, (bp, fl) -> {
if (w.getFluidState(bp).getType() == fl) w.getFluidState(bp).tick(w, bp, w.getBlockState(bp));
});
if (o.has("snap") && o.get("snap").getAsBoolean()) {
result.append("tick ").append(w.time).append('\n');
for (String s : summary(w, before)) result.append(s).append('\n');
}
}
}
default -> throw new IllegalStateException(o.toString());
}
}
}
default -> throw new IllegalStateException(mode);
}
for (String s : summary(w, before)) result.append(s).append('\n');
pending(w, result);
result.append(w.log);
result.append("rng ").append(w.rng.nextLong()).append('\n');
}
}
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Shared driver of the block update / random tick / redstone differential harnesses.
Cases are JSON lines understood by `VanillaBlockUpdateOracle` (vanilla block code
over a map-backed headless ServerLevel) and `block_update_fixture` (the Rust
`Behaviours` over the same kind of level). Both print one result block per case
(state changes, pending scheduled ticks, side-effect log, RNG fingerprint); this
module generates cases, runs both sides and reports differing cases grouped by
the block under test.
"""
import argparse
import json
import os
import random
import subprocess
try:
from . import lab, test_rust_parity as base
except ImportError:
import lab
import test_rust_parity as base
NEIGHBOUR_PALETTE = [
'stone', 'dirt', 'grass_block', 'water', 'lava', 'sand', 'oak_log', 'oak_planks', 'farmland', 'snow', 'snow_block',
'ice', 'packed_ice', 'soul_sand', 'netherrack', 'mycelium', 'podzol', 'gravel', 'glass', 'oak_leaves', 'fire',
'redstone_block', 'redstone_wire', 'redstone_torch', 'torch', 'lever', 'iron_block', 'magma_block', 'bedrock',
'cobblestone', 'obsidian', 'tnt', 'oak_slab', 'oak_stairs', 'hopper', 'bamboo', 'cactus', 'sugar_cane', 'kelp',
'seagrass', 'vine', 'moss_block', 'dripstone_block', 'amethyst_block', 'calcite', 'clay', 'coarse_dirt', 'rooted_dirt',
]
DIRS = [(0, -1, 0), (0, 1, 0), (0, 0, -1), (0, 0, 1), (-1, 0, 0), (1, 0, 0)]
class Facts:
def __init__(self, resources):
facts = json.loads((resources / 'facts.json').read_text())
self.states = facts['states']
self.defaults = facts['defaults']
self.by_block = {}
for index, state in enumerate(self.states):
self.by_block.setdefault(state[0], []).append(index)
self.palette = [name for name in ('minecraft:' + n for n in NEIGHBOUR_PALETTE) if name in self.by_block]
self.names = list(self.by_block)
def describe(self, index):
return self.states[index][3].replace('Block{', '').replace('}', '', 1)
def neighbour(self, rng, own):
roll = rng.random()
if roll < 0.30:
return rng.choice(self.by_block[rng.choice(self.palette)])
if roll < 0.45:
return rng.choice(self.by_block[self.states[own][0]])
if roll < 0.60:
return rng.choice(self.by_block[rng.choice(self.names)])
return None
def cells(self, rng, origin_state, density=0.55, face_density=0.8, extent=2):
cells = [[0, 0, 0, origin_state]]
for x in range(-extent, extent + 1):
for y in range(-extent, extent + 1):
for z in range(-extent, extent + 1):
if (x, y, z) == (0, 0, 0):
continue
face = abs(x) + abs(y) + abs(z) == 1
if rng.random() > (face_density if face else density):
continue
state = self.neighbour(rng, origin_state)
if state is not None:
cells.append([x, y, z, state])
return cells
def environment(rng):
case = {'seed': rng.randrange(1 << 40), 'time': rng.randrange(0, 100000)}
roll = rng.random()
if roll < 0.5:
case['light'] = [15, 0]
elif roll < 0.7:
case['light'] = [0, 0]
elif roll < 0.85:
case['light'] = [rng.randrange(16), rng.randrange(16)]
else:
case['light'] = [15, 15]
if rng.random() < 0.1:
case['raining'] = True
case['thundering'] = rng.random() < 0.3
return case
def put(cells, pos, state):
for cell in cells:
if cell[:3] == list(pos):
cell[3] = state
return
cells.append([*pos, state])
def argument_parser(description):
parser = argparse.ArgumentParser(description=description)
parser.add_argument('--count', type=int, default=40000, help='cases to generate')
parser.add_argument('--seed', type=int, default=1)
parser.add_argument('--only', help='substring of the block name under test')
parser.add_argument('--show', type=int, default=1, help='examples printed per block')
parser.add_argument('--events', action='store_true', help='compare side-effect logs too (default: states, ticks, rng and events)')
parser.add_argument('--ignore', default='', help='comma separated line prefixes to ignore when comparing (e.g. S,G)')
return parser
def blocks_of(facts):
return facts.names
def run(command, **kwargs):
done = subprocess.run(command, capture_output=True, text=True, **kwargs)
if done.returncode:
print((done.stdout + done.stderr)[-3000:])
raise SystemExit(f'{command[0]} failed')
return done
def execute(name, cases, args, facts, origin_of=None):
"""Run `cases` on both sides; print a report and return the number of differing cases."""
lock, jars, libraries = base.verify_artifacts()
resources = lab.LOCAL / 'horizon-client' / lock['minecraft']['version']
work = lab.LOCAL / name
work.mkdir(parents=True, exist_ok=True)
(work / 'cases.jsonl').write_text(''.join(json.dumps(case) + '\n' for case in cases))
classes = work / 'classes'
classes.mkdir(exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
java_root = lab.ROOT / 'mods/horizon/src/test/java'
run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-sourcepath', str(java_root), '-nowarn',
'-cp', dependencies, '-d', str(classes), str(java_root / 'dev/horizonlab/parity/VanillaBlockUpdateOracle.java')])
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaBlockUpdateOracle',
str(resources), str(work / 'cases.jsonl'), str(work / 'native.txt')])
run(['cargo', 'run', '-q', '--release', '-p', 'horizon-world', '--example', 'block_update_fixture', '--',
str(resources), str(work / 'cases.jsonl'), str(work / 'rust.txt')], cwd=lab.ROOT, env={**os.environ, 'CARGO_BUILD_JOBS': '3'})
ignore = tuple(prefix + ' ' for prefix in args.ignore.split(',') if prefix)
native = split_cases((work / 'native.txt').read_text(), ignore)
rust = split_cases((work / 'rust.txt').read_text(), ignore)
assert len(native) == len(rust) == len(cases), (len(native), len(rust), len(cases))
groups = {}
errors = {}
for case, a, b in zip(cases, native, rust):
if a and a[0].startswith('error'):
errors.setdefault(a[0], []).append(case)
continue
if a != b:
groups.setdefault(origin_name(case, facts), []).append((case, a, b))
total = sum(len(v) for v in groups.values())
for kind, entries in sorted(errors.items(), key=lambda entry: -len(entry[1])):
print(f'oracle error x{len(entries)}: {kind} (e.g. {facts.describe(entries[0]["cells"][0][3])})')
for block, entries in sorted(groups.items(), key=lambda entry: -len(entry[1])):
print(f'{block}: {len(entries)} differing')
for case, a, b in entries[:args.show]:
print(' case ' + describe_case(case, facts))
print(' native ' + ' | '.join(a))
print(' rust ' + ' | '.join(b))
skipped = sum(len(v) for v in errors.values())
print(f'{len(cases) - total - skipped}/{len(cases) - skipped} cases match ({skipped} oracle errors)')
(work / 'differences.json').write_text(json.dumps({k: [[c, a, b] for c, a, b in v[:5]] for k, v in groups.items()}))
return total
def split_cases(text, ignore):
out = []
for block in text.split('case ')[1:]:
lines = block.splitlines()[1:]
out.append([line for line in lines if not line.startswith(ignore)] if ignore else lines)
return out
def origin_name(case, facts):
pos = case.get('pos', [0, 0, 0])
for cell in case['cells']:
if cell[:3] == pos:
return facts.states[cell[3]][0]
return 'minecraft:air'
def describe_case(case, facts):
pos = case.get('pos', [0, 0, 0])
parts = [case['mode'], facts.describe(next(c[3] for c in case['cells'] if c[:3] == pos))]
if case['mode'] == 'shape':
parts.append(f'dir={case["dir"]} neighbor={facts.describe(case["neighbor"])}')
if case['mode'] == 'neighbor':
parts.append(f'from={case["from"]}')
if case.get('light') not in (None, [15, 0]):
parts.append(f'light={case["light"]}')
if case.get('raining'):
parts.append('raining')
near = []
for dx, dy, dz in DIRS:
at = [pos[0] + dx, pos[1] + dy, pos[2] + dz]
state = next((c[3] for c in case['cells'] if c[:3] == at), None)
near.append('air' if state is None else facts.describe(state))
parts.append('faces=' + ';'.join(near))
return ' '.join(parts) + f' seed={case["seed"]} time={case.get("time")}'
def load_facts():
lock, _, _ = base.verify_artifacts()
return Facts(lab.LOCAL / 'horizon-client' / lock['minecraft']['version'])
def state_pool(facts, only):
return [i for i, s in enumerate(facts.states) if not only or only in s[0]]
def finish(differing):
raise SystemExit(1 if differing else 0)
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Compare vanilla updateShape and neighborChanged with horizon-world, state by state.
Every block state is put at the origin of a random neighbourhood; `shape` cases
call `BlockState.updateShape` for one direction with a (consistent) neighbour
state and compare the resulting state, requested scheduled ticks, side effects and
RNG use; `neighbor` cases call `handleNeighborChanged` from one direction.
"""
import random
try:
from . import block_update_harness as h
except ImportError:
import block_update_harness as h
def cases(facts, count, seed, only):
rng = random.Random(seed)
pool = h.state_pool(facts, only)
air = facts.defaults['minecraft:air']
for n in range(count):
state = pool[n % len(pool)]
direction = rng.randrange(6)
cells = facts.cells(rng, state)
case = {**h.environment(rng), 'pos': [0, 0, 0], 'dir': direction}
dx, dy, dz = h.DIRS[direction]
if n % 3 == 2:
case['mode'] = 'neighbor'
case['from'] = [dx, dy, dz]
else:
case['mode'] = 'shape'
neighbour = facts.neighbour(rng, state)
neighbour = air if neighbour is None else neighbour
h.put(cells, (dx, dy, dz), neighbour)
case['neighbor'] = neighbour
case['cells'] = cells
yield case
def main():
args = h.argument_parser(__doc__).parse_args()
facts = h.load_facts()
differing = h.execute('block-update-parity', list(cases(facts, args.count, args.seed, args.only)), args, facts)
h.finish(differing)
if __name__ == '__main__':
main()